diff --git a/.core_files.yaml b/.core_files.yaml index df69df45cb6b12f47f79535f65383dbdd4fb7451..4082c016d8f899a2319b80c176a3862a29beca94 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -46,7 +46,6 @@ base_platforms: &base_platforms # Extra components that trigger the full suite components: &components - - homeassistant/components/alert/** - homeassistant/components/alexa/** - homeassistant/components/application_credentials/** - homeassistant/components/auth/** diff --git a/.coveragerc b/.coveragerc index 6c31546e718384511b6c2624949ab8074b29ec33..9d33acf6c75baf482ff25154282b85596c8e855b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -37,6 +37,8 @@ omit = homeassistant/components/airnow/sensor.py homeassistant/components/airthings/__init__.py homeassistant/components/airthings/sensor.py + homeassistant/components/airthings_ble/__init__.py + homeassistant/components/airthings_ble/sensor.py homeassistant/components/airtouch4/__init__.py homeassistant/components/airtouch4/climate.py homeassistant/components/airtouch4/const.py @@ -159,6 +161,8 @@ omit = homeassistant/components/brunt/const.py homeassistant/components/brunt/cover.py homeassistant/components/bsblan/climate.py + homeassistant/components/bsblan/const.py + homeassistant/components/bsblan/entity.py homeassistant/components/bt_home_hub_5/device_tracker.py homeassistant/components/bt_smarthub/device_tracker.py homeassistant/components/buienradar/sensor.py @@ -405,9 +409,11 @@ omit = homeassistant/components/flick_electric/sensor.py homeassistant/components/flock/notify.py homeassistant/components/flume/__init__.py + homeassistant/components/flume/binary_sensor.py homeassistant/components/flume/coordinator.py homeassistant/components/flume/entity.py homeassistant/components/flume/sensor.py + homeassistant/components/flume/util.py homeassistant/components/folder/sensor.py homeassistant/components/folder_watcher/* homeassistant/components/foobot/sensor.py @@ -534,6 +540,7 @@ omit = homeassistant/components/hunterdouglas_powerview/entity.py homeassistant/components/hunterdouglas_powerview/model.py homeassistant/components/hunterdouglas_powerview/scene.py + homeassistant/components/hunterdouglas_powerview/select.py homeassistant/components/hunterdouglas_powerview/sensor.py homeassistant/components/hunterdouglas_powerview/shade_data.py homeassistant/components/hunterdouglas_powerview/util.py @@ -578,6 +585,7 @@ omit = homeassistant/components/intellifire/coordinator.py homeassistant/components/intellifire/entity.py homeassistant/components/intellifire/fan.py + homeassistant/components/intellifire/number.py homeassistant/components/intellifire/sensor.py homeassistant/components/intellifire/switch.py homeassistant/components/intesishome/* @@ -608,7 +616,6 @@ omit = homeassistant/components/izone/__init__.py homeassistant/components/izone/climate.py homeassistant/components/izone/discovery.py - homeassistant/components/jellyfin/__init__.py homeassistant/components/jellyfin/media_source.py homeassistant/components/joaoapps_join/* homeassistant/components/juicenet/__init__.py @@ -657,8 +664,6 @@ omit = homeassistant/components/kostal_plenticore/switch.py homeassistant/components/kwb/sensor.py homeassistant/components/lacrosse/sensor.py - homeassistant/components/lametric/notify.py - homeassistant/components/lametric/number.py homeassistant/components/lannouncer/notify.py homeassistant/components/lastfm/sensor.py homeassistant/components/launch_library/__init__.py @@ -855,6 +860,7 @@ omit = homeassistant/components/noaa_tides/sensor.py homeassistant/components/nobo_hub/__init__.py homeassistant/components/nobo_hub/climate.py + homeassistant/components/nobo_hub/sensor.py homeassistant/components/norway_air/air_quality.py homeassistant/components/notify_events/notify.py homeassistant/components/notion/__init__.py @@ -1101,11 +1107,10 @@ omit = homeassistant/components/sesame/lock.py homeassistant/components/seven_segments/image_processing.py homeassistant/components/seventeentrack/sensor.py - homeassistant/components/shelly/__init__.py homeassistant/components/shelly/binary_sensor.py homeassistant/components/shelly/climate.py + homeassistant/components/shelly/coordinator.py homeassistant/components/shelly/entity.py - homeassistant/components/shelly/light.py homeassistant/components/shelly/number.py homeassistant/components/shelly/sensor.py homeassistant/components/shelly/utils.py @@ -1161,6 +1166,7 @@ omit = homeassistant/components/smtp/notify.py homeassistant/components/snapcast/* homeassistant/components/snmp/* + homeassistant/components/snooz/__init__.py homeassistant/components/solaredge/__init__.py homeassistant/components/solaredge/coordinator.py homeassistant/components/solaredge/sensor.py @@ -1230,7 +1236,9 @@ omit = homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbee/__init__.py homeassistant/components/switchbee/button.py + homeassistant/components/switchbee/climate.py homeassistant/components/switchbee/coordinator.py + homeassistant/components/switchbee/cover.py homeassistant/components/switchbee/entity.py homeassistant/components/switchbee/light.py homeassistant/components/switchbee/switch.py @@ -1421,6 +1429,7 @@ omit = homeassistant/components/velbus/const.py homeassistant/components/velbus/cover.py homeassistant/components/velbus/diagnostics.py + homeassistant/components/velbus/entity.py homeassistant/components/velbus/light.py homeassistant/components/velbus/sensor.py homeassistant/components/velbus/switch.py @@ -1573,6 +1582,9 @@ omit = homeassistant/components/youless/const.py homeassistant/components/youless/sensor.py homeassistant/components/zabbix/* + homeassistant/components/zamg/__init__.py + homeassistant/components/zamg/const.py + homeassistant/components/zamg/coordinator.py homeassistant/components/zamg/sensor.py homeassistant/components/zamg/weather.py homeassistant/components/zengge/light.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ba2911dcf0c304318a3df9a17eee47264d267241..1711ab68fdee6137df571c00e36a81a5d031625c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ "postCreateCommand": "script/setup", "postStartCommand": "script/bootstrap", "containerEnv": { "DEVCONTAINER": "1" }, - "appPort": 8123, + "appPort": ["8123:8123"], "runArgs": ["-e", "GIT_EDITOR=code --wait"], "extensions": [ "ms-python.vscode-pylance", @@ -17,8 +17,14 @@ // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json "settings": { "python.pythonPath": "/usr/local/bin/python", - "python.linting.pylintEnabled": true, "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.blackPath": "/usr/local/bin/black", + "python.linting.flake8Path": "/usr/local/bin/flake8", + "python.linting.pycodestylePath": "/usr/local/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/bin/pydocstyle", + "python.linting.mypyPath": "/usr/local/bin/mypy", + "python.linting.pylintPath": "/usr/local/bin/pylint", "python.formatting.provider": "black", "python.testing.pytestArgs": ["--no-cov"], "editor.formatOnPaste": false, diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6b13f0980b199f86dca6acf102f02b35470e1a8f..0390910cc58ad00846c96e3985e3273203355ec8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -46,9 +46,9 @@ body: attributes: label: What type of installation are you running? description: > - Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/). + Can be found in: [Settings -> System-> Repairs -> Three Dots in Upper Right -> System information](https://my.home-assistant.io/redirect/system_health/). - [](https://my.home-assistant.io/redirect/info/) + [](https://my.home-assistant.io/redirect/system_health/) options: - Home Assistant OS - Home Assistant Container diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 52d2522693099ce00eead993b4110a4c56dc2cea..23b355a223fd5ed81f8e6887f3daa3dbefa602eb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -75,18 +75,6 @@ If the code communicates with devices, web services, or third-party tools: - [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description. - [ ] Untested files have been added to `.coveragerc`. -The integration reached or maintains the following [Integration Quality Scale][quality-scale]: -<!-- - The Integration Quality Scale scores an integration on the code quality - and user experience. Each level of the quality scale consists of a list - of requirements. We highly recommend getting your integration scored! ---> - -- [ ] No score or internal -- [ ] 🥈 Silver -- [ ] 🥇 Gold -- [ ] 🆠Platinum - <!-- This project is very active and we have a high turnover of pull requests. diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5516af1ab4da898a47a1876e171bd05f983da4c5..2f844314eaa171be2e61988ad7b807650ab8ecb2 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,12 +24,12 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 with: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -67,10 +67,10 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -100,7 +100,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -115,7 +115,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -146,13 +146,13 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to DockerHub - uses: docker/login-action@v2.0.0 + uses: docker/login-action@v2.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2.0.0 + uses: docker/login-action@v2.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -198,7 +198,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set build additional args run: | @@ -212,13 +212,13 @@ jobs: fi - name: Login to DockerHub - uses: docker/login-action@v2.0.0 + uses: docker/login-action@v2.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2.0.0 + uses: docker/login-action@v2.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -241,7 +241,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -280,18 +280,18 @@ jobs: - "homeassistant" steps: - name: Checkout the repository - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Login to DockerHub if: matrix.registry == 'homeassistant' - uses: docker/login-action@v2.0.0 + uses: docker/login-action@v2.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry if: matrix.registry == 'ghcr.io/home-assistant' - uses: docker/login-action@v2.0.0 + uses: docker/login-action@v2.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7209a0fbf6f76bfa19979236c01213f5a15b3f20..fc0ca593fbd811fbd4f816bf953b9ad1090e4561 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,13 +20,11 @@ on: type: boolean env: - CACHE_VERSION: 1 - PIP_CACHE_VERSION: 1 - HA_SHORT_VERSION: 2022.10 - # Pin latest Python patch versions to avoid issues - # with runners using different versions. - DEFAULT_PYTHON: 3.9.14 - ALL_PYTHON_VERSIONS: "['3.9.14', '3.10.7']" + CACHE_VERSION: 3 + PIP_CACHE_VERSION: 3 + HA_SHORT_VERSION: 2022.11 + DEFAULT_PYTHON: 3.9 + ALL_PYTHON_VERSIONS: "['3.9', '3.10']" PRE_COMMIT_CACHE: ~/.cache/pre-commit PIP_CACHE: /tmp/pip-cache SQLALCHEMY_WARN_20: 1 @@ -58,21 +56,21 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- - echo "::set-output name=key::venv-${{ env.CACHE_VERSION }}-${{ + echo "key=venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements_test.txt') }}-${{ hashFiles('requirements_all.txt') }}-${{ - hashFiles('homeassistant/package_constraints.txt') }}" + hashFiles('homeassistant/package_constraints.txt') }}" >> $GITHUB_OUTPUT - name: Generate partial pre-commit restore key id: generate_pre-commit_cache_key run: >- - echo "::set-output name=key::${{ env.CACHE_VERSION }}-${{ env.DEFAULT_PYTHON }}-${{ - hashFiles('.pre-commit-config.yaml') }}" + echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{ + hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Filter for core changes - uses: dorny/paths-filter@v2.10.2 + uses: dorny/paths-filter@v2.11.1 id: core with: filters: .core_files.yaml @@ -87,7 +85,7 @@ jobs: echo "Result:" cat .integration_paths.yaml - name: Filter for integration changes - uses: dorny/paths-filter@v2.10.2 + uses: dorny/paths-filter@v2.11.1 id: integrations with: filters: .integration_paths.yaml @@ -148,19 +146,19 @@ jobs: # Output & sent to GitHub Actions echo "python_versions: ${ALL_PYTHON_VERSIONS}" - echo "::set-output name=python_versions::${ALL_PYTHON_VERSIONS}" + echo "python_versions=${ALL_PYTHON_VERSIONS}" >> $GITHUB_OUTPUT echo "test_full_suite: ${test_full_suite}" - echo "::set-output name=test_full_suite::${test_full_suite}" + echo "test_full_suite=${test_full_suite}" >> $GITHUB_OUTPUT echo "integrations_glob: ${integrations_glob}" - echo "::set-output name=integrations_glob::${integrations_glob}" + echo "integrations_glob=${integrations_glob}" >> $GITHUB_OUTPUT echo "test_group_count: ${test_group_count}" - echo "::set-output name=test_group_count::${test_group_count}" + echo "test_group_count=${test_group_count}" >> $GITHUB_OUTPUT echo "test_groups: ${test_groups}" - echo "::set-output name=test_groups::${test_groups}" + echo "test_groups=${test_groups}" >> $GITHUB_OUTPUT echo "tests: ${tests}" - echo "::set-output name=tests::${tests}" + echo "tests=${tests}" >> $GITHUB_OUTPUT echo "tests_glob: ${tests_glob}" - echo "::set-output name=tests_glob::${tests_glob}" + echo "tests_glob=${tests_glob}" >> $GITHUB_OUTPUT pre-commit: name: Prepare pre-commit base @@ -169,18 +167,21 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: venv - key: ${{ runner.os }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + needs.info.outputs.pre-commit_cache_key }} - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -190,10 +191,12 @@ jobs: pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-pre-commit-${{ needs.info.outputs.pre-commit_cache_key }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + needs.info.outputs.pre-commit_cache_key }} - name: Install pre-commit dependencies if: steps.cache-precommit.outputs.cache-hit != 'true' run: | @@ -208,18 +211,21 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: venv - key: ${{ runner.os }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + needs.info.outputs.pre-commit_cache_key }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -227,10 +233,12 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-pre-commit-${{ needs.info.outputs.pre-commit_cache_key }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + needs.info.outputs.pre-commit_cache_key }} - name: Fail job if pre-commit cache restore failed if: steps.cache-precommit.outputs.cache-hit != 'true' run: | @@ -257,18 +265,21 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: venv - key: ${{ runner.os }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + needs.info.outputs.pre-commit_cache_key }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -276,10 +287,12 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-pre-commit-${{ needs.info.outputs.pre-commit_cache_key }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + needs.info.outputs.pre-commit_cache_key }} - name: Fail job if pre-commit cache restore failed if: steps.cache-precommit.outputs.cache-hit != 'true' run: | @@ -309,18 +322,21 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: venv - key: ${{ runner.os }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + needs.info.outputs.pre-commit_cache_key }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -328,10 +344,12 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-pre-commit-${{ needs.info.outputs.pre-commit_cache_key }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + needs.info.outputs.pre-commit_cache_key }} - name: Fail job if pre-commit cache restore failed if: steps.cache-precommit.outputs.cache-hit != 'true' run: | @@ -350,18 +368,21 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: venv - key: ${{ runner.os }}-venv-${{ needs.info.outputs.pre-commit_cache_key }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ + needs.info.outputs.pre-commit_cache_key }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -369,10 +390,12 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: ${{ env.PRE_COMMIT_CACHE }} - key: ${{ runner.os }}-pre-commit-${{ needs.info.outputs.pre-commit_cache_key }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + needs.info.outputs.pre-commit_cache_key }} - name: Fail job if pre-commit cache restore failed if: steps.cache-precommit.outputs.cache-hit != 'true' run: | @@ -472,20 +495,21 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ matrix.python-version }} + check-latest: true - name: Generate partial pip restore key id: generate-pip-key run: >- - echo "::set-output name=key::pip-${{ env.PIP_CACHE_VERSION }}-${{ - env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" + echo "key=pip-${{ env.PIP_CACHE_VERSION }}-${{ + env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: venv key: >- @@ -493,7 +517,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: ${{ env.PIP_CACHE }} key: >- @@ -522,7 +546,7 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<22.3" setuptools wheel + pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<22.4" setuptools wheel pip install --cache-dir=$PIP_CACHE -r requirements_all.txt --use-deprecated=legacy-resolver pip install --cache-dir=$PIP_CACHE -r requirements_test.txt --use-deprecated=legacy-resolver pip install -e . @@ -535,15 +559,16 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: venv key: >- @@ -567,15 +592,16 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: venv key: >- @@ -600,15 +626,16 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: venv key: >- @@ -644,15 +671,16 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: venv key: >- @@ -692,15 +720,16 @@ jobs: name: Run pip check ${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ matrix.python-version }} + check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: venv key: >- @@ -746,15 +775,16 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ matrix.python-version }} + check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.8 + uses: actions/cache@v3.0.11 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -823,7 +853,7 @@ jobs: -p no:sugar \ tests/components/${{ matrix.group }} - name: Upload coverage artifact - uses: actions/upload-artifact@v3.1.0 + uses: actions/upload-artifact@v3.1.1 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -831,6 +861,109 @@ jobs: run: | ./script/check_dirty + pytest-mariadb: + runs-on: ubuntu-20.04 + services: + mariadb: + image: mariadb:10.9.3 + ports: + - 3306:3306 + env: + MYSQL_ROOT_PASSWORD: password + options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3 + if: | + (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') + && github.event.inputs.lint-only != 'true' + && needs.info.outputs.test_full_suite == 'true' + needs: + - info + - base + - gen-requirements-all + - hassfest + - lint-black + - lint-other + - lint-isort + - mypy + strategy: + fail-fast: false + matrix: + python-version: ${{ fromJson(needs.info.outputs.python_versions) }} + name: >- + Run tests Python ${{ matrix.python-version }} (mariadb) + steps: + - name: Install additional OS dependencies + run: | + sudo apt-get update + sudo apt-get -y install \ + bluez \ + ffmpeg \ + libmariadb-dev-compat + - name: Check out code from GitHub + uses: actions/checkout@v3.1.0 + - name: Set up Python ${{ matrix.python-version }} + id: python + uses: actions/setup-python@v4.3.0 + with: + python-version: ${{ matrix.python-version }} + check-latest: true + - name: Restore full Python ${{ matrix.python-version }} virtual environment + id: cache-venv + uses: actions/cache@v3.0.11 + with: + path: venv + key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + needs.info.outputs.python_cache_key }} + - name: Fail job if Python cache restore failed + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + echo "Failed to restore Python virtual environment from cache" + exit 1 + - name: Register Python problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/python.json" + - name: Install Pytest Annotation plugin + run: | + . venv/bin/activate + # Ideally this should be part of our dependencies + # However this plugin is fairly new and doesn't run correctly + # on a non-GitHub environment. + pip install pytest-github-actions-annotate-failures==0.1.3 + - name: Register pytest slow test problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" + - name: Install SQL Python libraries + run: | + . venv/bin/activate + pip install mysqlclient sqlalchemy_utils + - name: Run pytest (partially) + timeout-minutes: 10 + shell: bash + run: | + . venv/bin/activate + python --version + + python3 -X dev -m pytest \ + -qq \ + --timeout=9 \ + -n 1 \ + --cov="homeassistant.components.recorder" \ + --cov-report=xml \ + --cov-report=term-missing \ + -o console_output_style=count \ + --durations=0 \ + --durations-min=10 \ + -p no:sugar \ + --dburl=mysql://root:password@127.0.0.1/homeassistant-test \ + tests/components/recorder + - name: Upload coverage artifact + uses: actions/upload-artifact@v3.1.1 + with: + name: coverage-${{ matrix.python-version }}-mariadb + path: coverage.xml + - name: Check dirty + run: | + ./script/check_dirty + coverage: name: Upload test coverage to Codecov runs-on: ubuntu-20.04 @@ -839,7 +972,7 @@ jobs: - pytest steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index f6f7d24fff12d3012f6d5f8fcc38eb503077fc53..914fa4150514e89e17049a11a2bad42497f9bd5c 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: # - No PRs marked as no-stale # - No issues marked as no-stale or help-wanted - name: 90 days stale issues & PRs policy - uses: actions/stale@v6.0.0 + uses: actions/stale@v6.0.1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 90 @@ -54,7 +54,7 @@ jobs: # - No PRs marked as no-stale or new-integrations # - No issues (-1) - name: 30 days stale PRs policy - uses: actions/stale@v6.0.0 + uses: actions/stale@v6.0.1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 30 @@ -79,7 +79,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v6.0.0 + uses: actions/stale@v6.0.1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} only-labels: "needs-more-information" diff --git a/.github/workflows/translations.yaml b/.github/workflows/translations.yaml index bc9fa63c86c9988978f1fc66359227185afcfea2..cbf8e26c9ecc43e1ec6234dea6cadb5b65b99485 100644 --- a/.github/workflows/translations.yaml +++ b/.github/workflows/translations.yaml @@ -21,10 +21,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -40,10 +40,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.1.0 + uses: actions/setup-python@v4.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 2ffc2f1f721fffced80aa4673533abe04cc33003..672cf4fe4cb5ec6209770bb3440e5521e43c4822 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -22,7 +22,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Get information id: info @@ -57,13 +57,13 @@ jobs: ) > .env_file - name: Upload env_file - uses: actions/upload-artifact@v3.1.0 + uses: actions/upload-artifact@v3.1.1 with: name: env_file path: ./.env_file - name: Upload requirements_diff - uses: actions/upload-artifact@v3.1.0 + uses: actions/upload-artifact@v3.1.1 with: name: requirements_diff path: ./requirements_diff.txt @@ -79,7 +79,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -92,7 +92,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2022.06.7 + uses: home-assistant/wheels@2022.10.1 with: abi: cp310 tag: musllinux_1_2 @@ -116,7 +116,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3.1.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -140,7 +140,6 @@ jobs: 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|# 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 @@ -161,7 +160,7 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels - uses: home-assistant/wheels@2022.06.7 + uses: home-assistant/wheels@2022.10.1 with: abi: cp310 tag: musllinux_1_2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 088099bf4e44f28da8f152ee61ea13e51f28be26..751c97ebbb479e3f69f04a57dbc048fa8e4cbfac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.38.0 + rev: v3.1.0 hooks: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/psf/black - rev: 22.8.0 + rev: 22.10.0 hooks: - id: black args: @@ -61,7 +61,7 @@ repos: - --branch=master - --branch=rc - repo: https://github.com/adrienverge/yamllint.git - rev: v1.27.1 + rev: v1.28.0 hooks: - id: yamllint - repo: https://github.com/pre-commit/mirrors-prettier @@ -69,11 +69,12 @@ repos: hooks: - id: prettier - repo: https://github.com/cdce8p/python-typing-update - rev: v0.3.5 + rev: v0.5.0 hooks: # Run `python-typing-update` hook manually from time to time # to update python typing syntax. # Will require manual work, before submitting changes! + # pre-commit run --hook-stage manual python-typing-update --all-files - id: python-typing-update stages: [manual] args: @@ -113,7 +114,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(homeassistant/.+/(manifest|strings)\.json|\.coveragerc|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py)$ + files: ^(homeassistant/.+/(manifest|strings)\.json|homeassistant/brands/.*\.json|\.coveragerc|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py)$ - id: hassfest-metadata name: hassfest-metadata entry: script/run-in-env.sh python3 -m script.hassfest -p metadata diff --git a/.strict-typing b/.strict-typing index cc90af1b98bdd5c625a7af33b1cdb5c0ce4c9295..e79fb6b1a26a559834d40808d2a30c7d6fb23e40 100644 --- a/.strict-typing +++ b/.strict-typing @@ -2,7 +2,7 @@ # If component is fully covered with type annotations, please add it here # to enable strict mypy checks. -# Stict typing is enabled by default for core files. +# Strict typing is enabled by default for core files. # Add it here to add 'disallow_any_generics'. # --- Only for core file! --- homeassistant.exceptions @@ -57,6 +57,7 @@ homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* homeassistant.components.anthemav.* +homeassistant.components.aqualogic.* homeassistant.components.aseko_pool_live.* homeassistant.components.asuswrt.* homeassistant.components.auth.* @@ -64,7 +65,9 @@ homeassistant.components.automation.* homeassistant.components.awair.* homeassistant.components.backup.* homeassistant.components.baf.* +homeassistant.components.bayesian.* homeassistant.components.binary_sensor.* +homeassistant.components.blockchain.* homeassistant.components.bluetooth.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* @@ -77,6 +80,8 @@ homeassistant.components.calendar.* homeassistant.components.camera.* homeassistant.components.canary.* homeassistant.components.cover.* +homeassistant.components.clickatell.* +homeassistant.components.clicksend.* homeassistant.components.cpuspeed.* homeassistant.components.crownstone.* homeassistant.components.deconz.* @@ -116,6 +121,7 @@ homeassistant.components.geocaching.* homeassistant.components.gios.* homeassistant.components.goalzero.* homeassistant.components.google.* +homeassistant.components.google_sheets.* homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* @@ -160,6 +166,7 @@ homeassistant.components.lacrosse_view.* homeassistant.components.lametric.* homeassistant.components.laundrify.* homeassistant.components.lcn.* +homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* homeassistant.components.litterrobot.* @@ -208,6 +215,7 @@ homeassistant.components.pvoutput.* homeassistant.components.qnap_qsw.* homeassistant.components.rainmachine.* homeassistant.components.rdw.* +homeassistant.components.radarr.* homeassistant.components.recollect_waste.* homeassistant.components.recorder.* homeassistant.components.remote.* @@ -230,9 +238,12 @@ homeassistant.components.sensor.* homeassistant.components.senz.* homeassistant.components.shelly.* homeassistant.components.simplisafe.* +homeassistant.components.skybell.* homeassistant.components.slack.* homeassistant.components.sleepiq.* homeassistant.components.smhi.* +homeassistant.components.snooz.* +homeassistant.components.sonarr.* homeassistant.components.ssdp.* homeassistant.components.statistics.* homeassistant.components.steamist.* @@ -249,6 +260,7 @@ homeassistant.components.tag.* homeassistant.components.tailscale.* homeassistant.components.tautulli.* homeassistant.components.tcp.* +homeassistant.components.tibber.* homeassistant.components.tile.* homeassistant.components.tilt_ble.* homeassistant.components.tolo.* @@ -260,6 +272,7 @@ homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* +homeassistant.components.unifi.update homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* homeassistant.components.update.* @@ -279,6 +292,7 @@ homeassistant.components.websocket_api.* homeassistant.components.wemo.* homeassistant.components.whois.* homeassistant.components.wiz.* +homeassistant.components.wled.* homeassistant.components.worldclock.* homeassistant.components.yale_smart_alarm.* homeassistant.components.zeroconf.* diff --git a/CODEOWNERS b/CODEOWNERS index a01d358208b5fd2520ef381b4f41545b5bc2096e..acc723a3493896a7a6399146366cdda00c1b5a3a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -19,6 +19,8 @@ build.json @home-assistant/supervisor # Other code /homeassistant/scripts/check_config.py @kellerza +/homeassistant/const.py @epenet +/homeassistant/util/ @epenet # Integrations /homeassistant/components/abode/ @shred86 @@ -45,6 +47,8 @@ build.json @home-assistant/supervisor /tests/components/airnow/ @asymworks /homeassistant/components/airthings/ @danielhiversen /tests/components/airthings/ @danielhiversen +/homeassistant/components/airthings_ble/ @vincegio +/tests/components/airthings_ble/ @vincegio /homeassistant/components/airtouch4/ @LonePurpleWolf /tests/components/airtouch4/ @LonePurpleWolf /homeassistant/components/airvisual/ @bachya @@ -55,8 +59,8 @@ build.json @home-assistant/supervisor /tests/components/aladdin_connect/ @mkmer /homeassistant/components/alarm_control_panel/ @home-assistant/core /tests/components/alarm_control_panel/ @home-assistant/core -/homeassistant/components/alert/ @home-assistant/core -/tests/components/alert/ @home-assistant/core +/homeassistant/components/alert/ @home-assistant/core @frenck +/tests/components/alert/ @home-assistant/core @frenck /homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy /tests/components/alexa/ @home-assistant/cloud @ochlocracy /homeassistant/components/almond/ @gcampax @balloob @@ -281,7 +285,6 @@ build.json @home-assistant/supervisor /homeassistant/components/ecovacs/ @OverloadUT @mib1185 /homeassistant/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli -/homeassistant/components/edl21/ @mtdcr /homeassistant/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt @@ -566,8 +569,8 @@ build.json @home-assistant/supervisor /tests/components/isy994/ @bdraco @shbatm /homeassistant/components/izone/ @Swamp-Ig /tests/components/izone/ @Swamp-Ig -/homeassistant/components/jellyfin/ @j-stienstra -/tests/components/jellyfin/ @j-stienstra +/homeassistant/components/jellyfin/ @j-stienstra @ctalkington +/tests/components/jellyfin/ @j-stienstra @ctalkington /homeassistant/components/jewish_calendar/ @tsvi /tests/components/jewish_calendar/ @tsvi /homeassistant/components/juicenet/ @jesserockz @@ -602,8 +605,8 @@ build.json @home-assistant/supervisor /tests/components/kulersky/ @emlove /homeassistant/components/lacrosse_view/ @IceBotYT /tests/components/lacrosse_view/ @IceBotYT -/homeassistant/components/lametric/ @robbiet480 @frenck -/tests/components/lametric/ @robbiet480 @frenck +/homeassistant/components/lametric/ @robbiet480 @frenck @bachya +/tests/components/lametric/ @robbiet480 @frenck @bachya /homeassistant/components/landisgyr_heat_meter/ @vpathuis /tests/components/landisgyr_heat_meter/ @vpathuis /homeassistant/components/launch_library/ @ludeeus @DurgNomis-drol @@ -794,8 +797,8 @@ build.json @home-assistant/supervisor /tests/components/omnilogic/ @oliver84 @djtimca @gentoosu /homeassistant/components/onboarding/ @home-assistant/core /tests/components/onboarding/ @home-assistant/core -/homeassistant/components/oncue/ @bdraco -/tests/components/oncue/ @bdraco +/homeassistant/components/oncue/ @bdraco @peterager +/tests/components/oncue/ @bdraco @peterager /homeassistant/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP /homeassistant/components/onewire/ @garbled1 @epenet @@ -819,6 +822,8 @@ build.json @home-assistant/supervisor /tests/components/openweathermap/ @fabaff @freekode @nzapponi /homeassistant/components/opnsense/ @mtreinish /tests/components/opnsense/ @mtreinish +/homeassistant/components/oralb/ @bdraco +/tests/components/oralb/ @bdraco /homeassistant/components/oru/ @bvlaicu /homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne /tests/components/overkiz/ @imicknl @vlebourl @tetienne @@ -930,8 +935,6 @@ build.json @home-assistant/supervisor /tests/components/rhasspy/ @balloob @synesthesiam /homeassistant/components/ridwell/ @bachya /tests/components/ridwell/ @bachya -/homeassistant/components/ring/ @balloob -/tests/components/ring/ @balloob /homeassistant/components/risco/ @OnFreund /tests/components/risco/ @OnFreund /homeassistant/components/rituals_perfume_genie/ @milanmeu @@ -964,8 +967,8 @@ build.json @home-assistant/supervisor /homeassistant/components/schedule/ @home-assistant/core /tests/components/schedule/ @home-assistant/core /homeassistant/components/schluter/ @prairieapps -/homeassistant/components/scrape/ @fabaff -/tests/components/scrape/ @fabaff +/homeassistant/components/scrape/ @fabaff @gjohansson-ST @epenet +/tests/components/scrape/ @fabaff @gjohansson-ST @epenet /homeassistant/components/screenlogic/ @dieselrabbit @bdraco /tests/components/screenlogic/ @dieselrabbit @bdraco /homeassistant/components/script/ @home-assistant/core @@ -1039,6 +1042,8 @@ build.json @home-assistant/supervisor /homeassistant/components/smhi/ @gjohansson-ST /tests/components/smhi/ @gjohansson-ST /homeassistant/components/sms/ @ocalvo +/homeassistant/components/snooz/ @AustinBrunkhorst +/tests/components/snooz/ @AustinBrunkhorst /homeassistant/components/solaredge/ @frenck /tests/components/solaredge/ @frenck /homeassistant/components/solaredge_local/ @drobtravels @scheric @@ -1198,8 +1203,8 @@ build.json @home-assistant/supervisor /tests/components/upcloud/ @scop /homeassistant/components/update/ @home-assistant/core /tests/components/update/ @home-assistant/core -/homeassistant/components/upnp/ @StevenLooman @ehendrix23 -/tests/components/upnp/ @StevenLooman @ehendrix23 +/homeassistant/components/upnp/ @StevenLooman +/tests/components/upnp/ @StevenLooman /homeassistant/components/uptime/ @frenck /tests/components/uptime/ @frenck /homeassistant/components/uptimerobot/ @ludeeus @chemelli74 @@ -1293,8 +1298,8 @@ build.json @home-assistant/supervisor /tests/components/xiaomi_aqara/ @danielhiversen @syssi /homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79 /tests/components/xiaomi_ble/ @Jc2k @Ernst79 -/homeassistant/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG @bieniu -/tests/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG @bieniu +/homeassistant/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG +/tests/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG /homeassistant/components/xiaomi_tv/ @simse /homeassistant/components/xmpp/ @fabaff @flowolf /homeassistant/components/yale_smart_alarm/ @gjohansson-ST @@ -1313,6 +1318,8 @@ build.json @home-assistant/supervisor /tests/components/yolink/ @matrixd2 /homeassistant/components/youless/ @gjong /tests/components/youless/ @gjong +/homeassistant/components/zamg/ @killer0071234 +/tests/components/zamg/ @killer0071234 /homeassistant/components/zengge/ @emontnemery /homeassistant/components/zeroconf/ @bdraco /tests/components/zeroconf/ @bdraco diff --git a/Dockerfile.dev b/Dockerfile.dev index 0559ebb43cd1df8dde87bb6c0cd9de4adafdd461..fc9843461a03f5318d1f976ba1e134a9a45c6c02 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -2,6 +2,15 @@ FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.9 SHELL ["/bin/bash", "-o", "pipefail", "-c"] +# Uninstall pre-installed formatting and linting tools +# They would conflict with our pinned versions +RUN pipx uninstall black +RUN pipx uninstall flake8 +RUN pipx uninstall pydocstyle +RUN pipx uninstall pycodestyle +RUN pipx uninstall mypy +RUN pipx uninstall pylint + RUN \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ && apt-get update \ diff --git a/build.yaml b/build.yaml index 9cf66e2621a073a73f5ea87a374fb760b8cb5818..14a59641388ba2bf7a8a68d82b4a7ee0808745fe 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.07.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2022.07.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2022.07.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2022.07.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2022.07.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2022.10.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2022.10.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2022.10.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2022.10.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2022.10.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/docs/screenshot-integrations.png b/docs/screenshot-integrations.png index 7b5297340e72c01cb20f856db41623e4f2def610..23202a578f133c4954a66ddd6fd1dffd01c51742 100644 Binary files a/docs/screenshot-integrations.png and b/docs/screenshot-integrations.png differ diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index f2339e6bd1af0bd884f21801ffa1f375b7d838a4..31834c7b7a30c165d75478b9e6eb9435e0a4a023 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -21,7 +21,7 @@ from .components import http, persistent_notification from .const import ( REQUIRED_NEXT_PYTHON_HA_RELEASE, REQUIRED_NEXT_PYTHON_VER, - SIGNAL_BOOTSTRAP_INTEGRATONS, + SIGNAL_BOOTSTRAP_INTEGRATIONS, ) from .exceptions import HomeAssistantError from .helpers import ( @@ -431,7 +431,7 @@ async def _async_watch_pending_setups(hass: core.HomeAssistant) -> None: _LOGGER.debug("Integration remaining: %s", remaining_with_setup_started) if remaining_with_setup_started or not previous_was_empty: async_dispatcher_send( - hass, SIGNAL_BOOTSTRAP_INTEGRATONS, remaining_with_setup_started + hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, remaining_with_setup_started ) previous_was_empty = not remaining_with_setup_started await asyncio.sleep(SLOW_STARTUP_CHECK_INTERVAL) @@ -622,7 +622,7 @@ async def _async_set_up_integrations( _LOGGER.warning("Setup timed out for bootstrap - moving forward") watch_task.cancel() - async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATONS, {}) + async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, {}) _LOGGER.debug( "Integration setup times: %s", diff --git a/homeassistant/brands/airthings.json b/homeassistant/brands/airthings.json new file mode 100644 index 0000000000000000000000000000000000000000..e83546f9d6176cad577f2caf21d32104e9d119e4 --- /dev/null +++ b/homeassistant/brands/airthings.json @@ -0,0 +1,5 @@ +{ + "domain": "airthings", + "name": "Airthings", + "integrations": ["airthings", "airthings_ble"] +} diff --git a/homeassistant/brands/devolo.json b/homeassistant/brands/devolo.json index 86dc7a3b1009f9a91b80ba476ba86af21ac4bb0a..021c62b930b3c390f91c1e23d60c94fae1d179a1 100644 --- a/homeassistant/brands/devolo.json +++ b/homeassistant/brands/devolo.json @@ -1,5 +1,6 @@ { "domain": "devolo", "name": "devolo", - "integrations": ["devolo_home_control", "devolo_home_network"] + "integrations": ["devolo_home_control", "devolo_home_network"], + "iot_standards": ["zwave"] } diff --git a/homeassistant/brands/ikea.json b/homeassistant/brands/ikea.json new file mode 100644 index 0000000000000000000000000000000000000000..702a59ad4d1b08692996d5aa997ba8b3db1aa688 --- /dev/null +++ b/homeassistant/brands/ikea.json @@ -0,0 +1,5 @@ +{ + "domain": "ikea", + "name": "IKEA", + "integrations": ["symfonisk", "tradfri"] +} diff --git a/homeassistant/brands/raspberry.json b/homeassistant/brands/raspberry.json deleted file mode 100644 index a0ec6f126992b801848791af08d4ce797d17ec47..0000000000000000000000000000000000000000 --- a/homeassistant/brands/raspberry.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "domain": "raspberry_pi", - "name": "Raspberry Pi", - "integrations": ["rpi_camera", "rpi_power", "remote_rpi_gpio"] -} diff --git a/homeassistant/brands/raspberry_pi.json b/homeassistant/brands/raspberry_pi.json new file mode 100644 index 0000000000000000000000000000000000000000..a64da918ccc0ddddaecb75dc5527e3fa8509c830 --- /dev/null +++ b/homeassistant/brands/raspberry_pi.json @@ -0,0 +1,5 @@ +{ + "domain": "raspberry_pi", + "name": "Raspberry Pi", + "integrations": ["raspberry_pi", "rpi_camera", "rpi_power", "remote_rpi_gpio"] +} diff --git a/homeassistant/brands/u_tec.json b/homeassistant/brands/u_tec.json index 2ce4be9a7d9972fd888d2f0b99d771695c4834fb..f0c2cf8a6915fbe0ceda4aa07600b2da7b1406f5 100644 --- a/homeassistant/brands/u_tec.json +++ b/homeassistant/brands/u_tec.json @@ -1,5 +1,5 @@ { "domain": "u_tec", "name": "U-tec", - "iot_standards": ["zwave"] + "integrations": ["ultraloq"] } diff --git a/homeassistant/components/3_day_blinds/manifest.json b/homeassistant/components/3_day_blinds/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..5baf52cfac9910e05c720d41ea4f9c59261f0a4f --- /dev/null +++ b/homeassistant/components/3_day_blinds/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "3_day_blinds", + "name": "3 Day Blinds", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/abode/translations/bg.json b/homeassistant/components/abode/translations/bg.json index 955ed18c82c9ec7dccc7ff4c36069636ac3d0df1..a451dd3516ab3f24f30a88643f80d979a2eae1db 100644 --- a/homeassistant/components/abode/translations/bg.json +++ b/homeassistant/components/abode/translations/bg.json @@ -11,13 +11,13 @@ "reauth_confirm": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "Email" + "username": "\u0418\u043c\u0435\u0439\u043b" } }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "E-mail \u0430\u0434\u0440\u0435\u0441" + "username": "\u0418\u043c\u0435\u0439\u043b" }, "title": "\u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0412\u0430\u0448\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0437\u0430 \u0432\u0445\u043e\u0434 \u0432 Abode" } diff --git a/homeassistant/components/abode/translations/no.json b/homeassistant/components/abode/translations/no.json index 27706c3d7975753207705ecfc049746561557aab..6918b238d42a27d0bb9c52899eabe4db2b7c0584 100644 --- a/homeassistant/components/abode/translations/no.json +++ b/homeassistant/components/abode/translations/no.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "error": { diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 0484dd0c8e7aeaea8911d377d728f04e2c798229..89af284f87324db82d1ae733ae59f5ca38057058 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -17,6 +17,7 @@ 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.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER @@ -116,7 +117,7 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): current = await self.accuweather.async_get_current_conditions() forecast = ( await self.accuweather.async_get_forecast( - metric=self.hass.config.units.is_metric + metric=self.hass.config.units is METRIC_SYSTEM ) if self.forecast else {} diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index f92dca9dfeec4af790a7973ccbe750a079a40d5c..5bda281ff3c814de971465ffd0a709cafc0dd74a 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -7,5 +7,6 @@ "config_flow": true, "quality_scale": "platinum", "iot_class": "cloud_polling", - "loggers": ["accuweather"] + "loggers": ["accuweather"], + "integration_type": "service" } diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index f57af15714d9f36a0f4ca5dd5caeeaae7dc4b32a..78041c5309cb33af4ca3972a2989c941dbd48c5f 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -30,6 +30,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator from .const import ( @@ -189,6 +190,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( ), AccuWeatherSensorDescription( key="WindGustDay", + device_class=SensorDeviceClass.WIND_SPEED, icon="mdi:weather-windy", name="Wind gust day", entity_registry_enabled_default=False, @@ -200,6 +202,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( ), AccuWeatherSensorDescription( key="WindGustNight", + device_class=SensorDeviceClass.WIND_SPEED, icon="mdi:weather-windy", name="Wind gust night", entity_registry_enabled_default=False, @@ -211,6 +214,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( ), AccuWeatherSensorDescription( key="WindDay", + device_class=SensorDeviceClass.WIND_SPEED, icon="mdi:weather-windy", name="Wind day", unit_fn=lambda metric: SPEED_KILOMETERS_PER_HOUR @@ -221,6 +225,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( ), AccuWeatherSensorDescription( key="WindNight", + device_class=SensorDeviceClass.WIND_SPEED, icon="mdi:weather-windy", name="Wind night", unit_fn=lambda metric: SPEED_KILOMETERS_PER_HOUR @@ -243,6 +248,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( ), AccuWeatherSensorDescription( key="Ceiling", + device_class=SensorDeviceClass.DISTANCE, icon="mdi:weather-fog", name="Cloud ceiling", state_class=SensorStateClass.MEASUREMENT, @@ -329,6 +335,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( ), AccuWeatherSensorDescription( key="Wind", + device_class=SensorDeviceClass.WIND_SPEED, icon="mdi:weather-windy", name="Wind", state_class=SensorStateClass.MEASUREMENT, @@ -339,6 +346,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( ), AccuWeatherSensorDescription( key="WindGust", + device_class=SensorDeviceClass.WIND_SPEED, icon="mdi:weather-windy", name="Wind gust", entity_registry_enabled_default=False, @@ -405,12 +413,12 @@ class AccuWeatherSensor( self._attr_unique_id = ( f"{coordinator.location_key}-{description.key}".lower() ) - if self.coordinator.hass.config.units.is_metric: + if self.coordinator.hass.config.units is METRIC_SYSTEM: self._unit_system = API_METRIC else: self._unit_system = API_IMPERIAL self._attr_native_unit_of_measurement = self.entity_description.unit_fn( - self.coordinator.hass.config.units.is_metric + self.coordinator.hass.config.units is METRIC_SYSTEM ) self._attr_device_info = coordinator.device_info if forecast_day is not None: diff --git a/homeassistant/components/accuweather/translations/bg.json b/homeassistant/components/accuweather/translations/bg.json index 6cd4cdde80e831cf175a4afe54f3ffd5ffb9c646..8435bea00dfce0864d3dfb27086bda3c22d29234 100644 --- a/homeassistant/components/accuweather/translations/bg.json +++ b/homeassistant/components/accuweather/translations/bg.json @@ -4,7 +4,7 @@ "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\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447" }, "step": { diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 2bbbe7b91602eb6db47253967b8b3f797ed47b14..82db25288b88f844b05e271332e932a13cc40e81 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -33,6 +33,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp +from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator from .const import ( @@ -70,7 +71,7 @@ class AccuWeatherEntity( # 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.hass.config.units.is_metric: + if coordinator.hass.config.units is METRIC_SYSTEM: self._attr_native_precipitation_unit = LENGTH_MILLIMETERS self._attr_native_pressure_unit = PRESSURE_HPA self._attr_native_temperature_unit = TEMP_CELSIUS diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json index 408c099b8ac5ee0241f130a5c70e5f0917c4f5f2..cbe14f0d7a5273715a3400d18f877715eb5b4c39 100644 --- a/homeassistant/components/adax/manifest.json +++ b/homeassistant/components/adax/manifest.json @@ -3,7 +3,7 @@ "name": "Adax", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/adax", - "requirements": ["adax==0.2.0", "Adax-local==0.1.4"], + "requirements": ["adax==0.2.0", "Adax-local==0.1.5"], "codeowners": ["@danielhiversen"], "iot_class": "local_polling", "loggers": ["adax", "adax_local"] diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json index cf1210b0884e7e4170560c764229b20696c16389..91e1393c73492fcbf0f28d6860b483f878f3576b 100644 --- a/homeassistant/components/adguard/manifest.json +++ b/homeassistant/components/adguard/manifest.json @@ -6,5 +6,6 @@ "requirements": ["adguardhome==0.5.1"], "codeowners": ["@frenck"], "iot_class": "local_polling", + "integration_type": "service", "loggers": ["adguardhome"] } diff --git a/homeassistant/components/adguard/translations/bg.json b/homeassistant/components/adguard/translations/bg.json index 9838fd97c1349c5947a6fcead9c4bc998e00938b..6ee3d4bd8fc6b2803ff37117b5b8ce653a8d926a 100644 --- a/homeassistant/components/adguard/translations/bg.json +++ b/homeassistant/components/adguard/translations/bg.json @@ -1,6 +1,7 @@ { "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", "existing_instance_updated": "\u0410\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430\u0442\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." }, "error": { diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 645c1ad0ea285f89cfcc565101418ce6d273fe9a..7058257d80864e1d59e25ed419c4903a2ef60e70 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -21,11 +21,11 @@ from homeassistant.components.weather import ( from homeassistant.const import ( DEGREE, PERCENTAGE, - PRECIPITATION_MILLIMETERS_PER_HOUR, PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, Platform, + UnitOfVolumetricFlux, ) ATTRIBUTION = "Powered by AEMET OpenData" @@ -208,7 +208,8 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=ATTR_API_FORECAST_PRECIPITATION, name="Precipitation", - native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key=ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, @@ -265,7 +266,8 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=ATTR_API_RAIN, name="Rain", - native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key=ATTR_API_RAIN_PROB, @@ -276,7 +278,8 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=ATTR_API_SNOW, name="Snow", - native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key=ATTR_API_SNOW_PROB, diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index a7ff3630e783afd4600b6ebe3aec7839eeb18e95..695ae283a4704aeb07e4a8a41dc9ee55aeeb19f8 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -84,8 +84,7 @@ async def async_setup_entry( unique_id = f"{config_entry.unique_id} {mode}" entities.append(AemetWeather(name, unique_id, weather_coordinator, mode)) - if entities: - async_add_entities(entities, False) + async_add_entities(entities, False) class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): diff --git a/homeassistant/components/airly/manifest.json b/homeassistant/components/airly/manifest.json index 56dd205de6899c79c9cd95edad1729fc7696edcc..91d2e82974189af2ae9176ff073b05bb53e1f4a1 100644 --- a/homeassistant/components/airly/manifest.json +++ b/homeassistant/components/airly/manifest.json @@ -7,5 +7,6 @@ "config_flow": true, "quality_scale": "platinum", "iot_class": "cloud_polling", - "loggers": ["airly"] + "loggers": ["airly"], + "integration_type": "service" } diff --git a/homeassistant/components/airnow/diagnostics.py b/homeassistant/components/airnow/diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..284fd65013b79eb91d2c38bd6e07ef49d554e061 --- /dev/null +++ b/homeassistant/components/airnow/diagnostics.py @@ -0,0 +1,53 @@ +"""Diagnostics support for AirNow.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UNIQUE_ID, +) +from homeassistant.core import HomeAssistant + +from . import AirNowDataUpdateCoordinator +from .const import DOMAIN + +ATTR_LATITUDE_CAP = "Latitude" +ATTR_LONGITUDE_CAP = "Longitude" +ATTR_REPORTING_AREA = "ReportingArea" +ATTR_STATE_CODE = "StateCode" + +CONF_TITLE = "title" + +TO_REDACT = { + ATTR_LATITUDE_CAP, + ATTR_LONGITUDE_CAP, + ATTR_REPORTING_AREA, + ATTR_STATE_CODE, + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + # The config entry title has latitude/longitude: + CONF_TITLE, + # The config entry unique ID has latitude/longitude: + CONF_UNIQUE_ID, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: AirNowDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + return async_redact_data( + { + "entry": entry.as_dict(), + "data": coordinator.data, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/airnow/translations/nb.json b/homeassistant/components/airnow/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/airnow/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings/translations/nb.json b/homeassistant/components/airthings/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/airthings/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/__init__.py b/homeassistant/components/airthings_ble/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d7e6bddbcd4e10a6d991432057f1b5d1cbd3c188 --- /dev/null +++ b/homeassistant/components/airthings_ble/__init__.py @@ -0,0 +1,74 @@ +"""The Airthings BLE integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from airthings_ble import AirthingsBluetoothDeviceData + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airthings BLE device from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + address = entry.unique_id + + elevation = hass.config.elevation + is_metric = hass.config.units is METRIC_SYSTEM + assert address is not None + + ble_device = bluetooth.async_ble_device_from_address(hass, address) + + if not ble_device: + raise ConfigEntryNotReady( + f"Could not find Airthings device with address {address}" + ) + + async def _async_update_method(): + """Get data from Airthings BLE.""" + ble_device = bluetooth.async_ble_device_from_address(hass, address) + airthings = AirthingsBluetoothDeviceData(_LOGGER, elevation, is_metric) + + try: + data = await airthings.update_device(ble_device) + except Exception as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + return data + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=_async_update_method, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..6d5df7ddd565684eab5e4d990f338949150e4e04 --- /dev/null +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -0,0 +1,169 @@ +"""Config flow for Airthings BlE integration.""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Any + +from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from bleak import BleakError +import voluptuous as vol + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import ( + BluetoothServiceInfo, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, MFCT_ID + +_LOGGER = logging.getLogger(__name__) + + +@dataclasses.dataclass +class Discovery: + """A discovered bluetooth device.""" + + name: str + discovery_info: BluetoothServiceInfo + device: AirthingsDevice + + +def get_name(device: AirthingsDevice) -> str: + """Generate name with identifier for device.""" + return f"{device.name} ({device.identifier})" + + +class AirthingsDeviceUpdateError(Exception): + """Custom error class for device updates.""" + + +class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Airthings BLE.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_device: Discovery | None = None + self._discovered_devices: dict[str, Discovery] = {} + + async def _get_device_data( + self, discovery_info: BluetoothServiceInfo + ) -> AirthingsDevice: + ble_device = bluetooth.async_ble_device_from_address( + self.hass, discovery_info.address + ) + if ble_device is None: + _LOGGER.debug("no ble_device in _get_device_data") + raise AirthingsDeviceUpdateError("No ble_device") + + airthings = AirthingsBluetoothDeviceData(_LOGGER) + + try: + data = await airthings.update_device(ble_device) + except BleakError as err: + _LOGGER.error( + "Error connecting to and getting data from %s: %s", + discovery_info.address, + err, + ) + raise AirthingsDeviceUpdateError("Failed getting device data") from err + except Exception as err: + _LOGGER.error( + "Unknown error occurred from %s: %s", discovery_info.address, err + ) + raise err + return data + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfo + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + _LOGGER.debug("Discovered BT device: %s", discovery_info) + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + + try: + device = await self._get_device_data(discovery_info) + except AirthingsDeviceUpdateError: + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + return self.async_abort(reason="unknown") + + name = get_name(device) + self.context["title_placeholders"] = {"name": name} + self._discovered_device = Discovery(name, discovery_info, device) + + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self.context["title_placeholders"]["name"], data={} + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders=self.context["title_placeholders"], + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + discovery = self._discovered_devices[address] + + self.context["title_placeholders"] = { + "name": discovery.name, + } + + self._discovered_device = discovery + + return self.async_create_entry(title=discovery.name, data={}) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + + if MFCT_ID not in discovery_info.manufacturer_data: + continue + + try: + device = await self._get_device_data(discovery_info) + except AirthingsDeviceUpdateError: + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + return self.async_abort(reason="unknown") + name = get_name(device) + self._discovered_devices[address] = Discovery(name, discovery_info, device) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + titles = { + address: get_name(discovery.device) + for (address, discovery) in self._discovered_devices.items() + } + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In(titles), + }, + ), + ) diff --git a/homeassistant/components/airthings_ble/const.py b/homeassistant/components/airthings_ble/const.py new file mode 100644 index 0000000000000000000000000000000000000000..96372919e70aeb2a37cbff757db168937a9f17a9 --- /dev/null +++ b/homeassistant/components/airthings_ble/const.py @@ -0,0 +1,9 @@ +"""Constants for Airthings BLE.""" + +DOMAIN = "airthings_ble" +MFCT_ID = 820 + +VOLUME_BECQUEREL = "Bq/m³" +VOLUME_PICOCURIE = "pCi/L" + +DEFAULT_SCAN_INTERVAL = 300 diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..dca2dbbb56262a48dfd1a922325acb8236a9992a --- /dev/null +++ b/homeassistant/components/airthings_ble/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "airthings_ble", + "name": "Airthings BLE", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airthings_ble", + "requirements": ["airthings-ble==0.5.2"], + "dependencies": ["bluetooth"], + "codeowners": ["@vincegio"], + "iot_class": "local_polling", + "bluetooth": [ + { + "manufacturer_id": 820 + } + ] +} diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..37b5ce6160e3f65f3d7994ffb97257b6b1f97392 --- /dev/null +++ b/homeassistant/components/airthings_ble/sensor.py @@ -0,0 +1,186 @@ +"""Support for airthings ble sensors.""" +from __future__ import annotations + +import logging + +from airthings_ble import AirthingsDevice + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, + PERCENTAGE, + PRESSURE_MBAR, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import DOMAIN, VOLUME_BECQUEREL, VOLUME_PICOCURIE + +_LOGGER = logging.getLogger(__name__) + +SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { + "radon_1day_avg": SensorEntityDescription( + key="radon_1day_avg", + native_unit_of_measurement=VOLUME_BECQUEREL, + name="Radon 1-day average", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:radioactive", + ), + "radon_longterm_avg": SensorEntityDescription( + key="radon_longterm_avg", + native_unit_of_measurement=VOLUME_BECQUEREL, + name="Radon longterm average", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:radioactive", + ), + "radon_1day_level": SensorEntityDescription( + key="radon_1day_level", + name="Radon 1-day level", + icon="mdi:radioactive", + ), + "radon_longterm_level": SensorEntityDescription( + key="radon_longterm_level", + name="Radon longterm level", + icon="mdi:radioactive", + ), + "temperature": SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + name="Temperature", + ), + "humidity": SensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + name="Humidity", + ), + "pressure": SensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=PRESSURE_MBAR, + name="Pressure", + ), + "battery": SensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + name="Battery", + ), + "co2": SensorEntityDescription( + key="co2", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + name="co2", + ), + "voc": SensorEntityDescription( + key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + name="VOC", + icon="mdi:cloud", + ), + "illuminance": SensorEntityDescription( + key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + name="Illuminance", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Airthings BLE sensors.""" + is_metric = hass.config.units is METRIC_SYSTEM + + coordinator: DataUpdateCoordinator[AirthingsDevice] = hass.data[DOMAIN][ + entry.entry_id + ] + + # we need to change some units + sensors_mapping = SENSORS_MAPPING_TEMPLATE.copy() + if not is_metric: + for val in sensors_mapping.values(): + if val.native_unit_of_measurement is not VOLUME_BECQUEREL: + continue + val.native_unit_of_measurement = VOLUME_PICOCURIE + + entities = [] + _LOGGER.debug("got sensors: %s", coordinator.data.sensors) + for sensor_type, sensor_value in coordinator.data.sensors.items(): + if sensor_type not in sensors_mapping: + _LOGGER.debug( + "Unknown sensor type detected: %s, %s", + sensor_type, + sensor_value, + ) + continue + entities.append( + AirthingsSensor(coordinator, coordinator.data, sensors_mapping[sensor_type]) + ) + + async_add_entities(entities) + + +class AirthingsSensor( + CoordinatorEntity[DataUpdateCoordinator[AirthingsDevice]], SensorEntity +): + """Airthings BLE sensors for the device.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator, + airthings_device: AirthingsDevice, + entity_description: SensorEntityDescription, + ) -> None: + """Populate the airthings entity with relevant data.""" + super().__init__(coordinator) + self.entity_description = entity_description + + name = f"{airthings_device.name} {airthings_device.identifier}" + + self._attr_unique_id = f"{name}_{entity_description.key}" + + self._id = airthings_device.address + self._attr_device_info = DeviceInfo( + connections={ + ( + CONNECTION_BLUETOOTH, + airthings_device.address, + ) + }, + name=name, + manufacturer="Airthings", + hw_version=airthings_device.hw_version, + sw_version=airthings_device.sw_version, + ) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data.sensors[self.entity_description.key] diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..1cfc4ccd592ea55f378fc4c49433fac1a4dbbf61 --- /dev/null +++ b/homeassistant/components/airthings_ble/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:component::bluetooth::config::step::user::data::address%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/airthings_ble/translations/bg.json b/homeassistant/components/airthings_ble/translations/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..3c3714804c489656cf3793bd3a81cb5a70f1914c --- /dev/null +++ b/homeassistant/components/airthings_ble/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", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "no_devices_found": "\u041d\u044f\u043c\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 {name}?" + }, + "user": { + "data": { + "address": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0437\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/ca.json b/homeassistant/components/airthings_ble/translations/ca.json new file mode 100644 index 0000000000000000000000000000000000000000..1b9d6bd2170068be533f4d247e3b2af8b37f2461 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "cannot_connect": "Ha fallat la connexi\u00f3", + "no_devices_found": "No s'han trobat dispositius a la xarxa", + "unknown": "Error inesperat" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Vols configurar {name}?" + }, + "user": { + "data": { + "address": "Dispositiu" + }, + "description": "Tria un dispositiu a configurar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/de.json b/homeassistant/components/airthings_ble/translations/de.json new file mode 100644 index 0000000000000000000000000000000000000000..0368cb1dd4e783868a04b0cdfdcd8434b1979137 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "cannot_connect": "Verbindung fehlgeschlagen", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", + "unknown": "Unerwarteter Fehler" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "M\u00f6chtest du {name} einrichten?" + }, + "user": { + "data": { + "address": "Ger\u00e4t" + }, + "description": "W\u00e4hle ein Ger\u00e4t zum Einrichten aus" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/el.json b/homeassistant/components/airthings_ble/translations/el.json new file mode 100644 index 0000000000000000000000000000000000000000..cdb92f71285ba0e673fcd282433e445aed3ed263 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/el.json @@ -0,0 +1,23 @@ +{ + "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_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", + "no_devices_found": "\u0394\u03b5\u03bd \u03b2\u03c1\u03ad\u03b8\u03b7\u03ba\u03b1\u03bd \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ad\u03c2 \u03c3\u03c4\u03bf \u03b4\u03af\u03ba\u03c4\u03c5\u03bf", + "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {name};" + }, + "user": { + "data": { + "address": "\u03a3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae" + }, + "description": "\u0395\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 \u03bc\u03b9\u03b1 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03b3\u03b9\u03b1 \u03c1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/en.json b/homeassistant/components/airthings_ble/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..245f0fecd2cae9026c2db6a339bd9247808d3591 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "cannot_connect": "Failed to connect", + "no_devices_found": "No devices found on the network", + "unknown": "Unexpected error" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Do you want to setup {name}?" + }, + "user": { + "data": { + "address": "Device" + }, + "description": "Choose a device to setup" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/es.json b/homeassistant/components/airthings_ble/translations/es.json new file mode 100644 index 0000000000000000000000000000000000000000..e39343de7998213afdc8f3804bd31675ae6b7321 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en curso", + "cannot_connect": "No se pudo conectar", + "no_devices_found": "No se encontraron dispositivos en la red", + "unknown": "Error inesperado" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u00bfQuieres configurar {name}?" + }, + "user": { + "data": { + "address": "Dispositivo" + }, + "description": "Elige un dispositivo para configurar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/et.json b/homeassistant/components/airthings_ble/translations/et.json new file mode 100644 index 0000000000000000000000000000000000000000..2e8cb22443b4ede126b2e671bf6904ff1746b183 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/et.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "already_in_progress": "Seadistamine on juba k\u00e4imas", + "cannot_connect": "\u00dchendamine nurjus", + "no_devices_found": "V\u00f5rgust seadmeid ei leitud", + "unknown": "Ootamatu t\u00f5rge" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Kas seadistada {name}?" + }, + "user": { + "data": { + "address": "Seade" + }, + "description": "Vali h\u00e4\u00e4lestatav seade" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/fr.json b/homeassistant/components/airthings_ble/translations/fr.json new file mode 100644 index 0000000000000000000000000000000000000000..89920f2b34583457895020e43cadb8a2c98232c5 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "cannot_connect": "\u00c9chec de connexion", + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "unknown": "Erreur inattendue" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Voulez-vous configurer {name}\u00a0?" + }, + "user": { + "data": { + "address": "Appareil" + }, + "description": "S\u00e9lectionnez l'appareil \u00e0 configurer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/he.json b/homeassistant/components/airthings_ble/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..3ba358c44651bd8a196d9ce877f2ab6eae40511b --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/he.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {name}?" + }, + "user": { + "data": { + "address": "\u05d4\u05ea\u05e7\u05df" + }, + "description": "\u05d1\u05d7\u05d9\u05e8\u05ea \u05d4\u05ea\u05e7\u05df \u05dc\u05d4\u05d2\u05d3\u05e8\u05d4" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/hu.json b/homeassistant/components/airthings_ble/translations/hu.json new file mode 100644 index 0000000000000000000000000000000000000000..416831e5b4f0c72cba0e0fb14771dc617861b433 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A be\u00e1ll\u00edt\u00e1si folyamat m\u00e1r el lett kezdve", + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?" + }, + "user": { + "data": { + "address": "Eszk\u00f6z" + }, + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/id.json b/homeassistant/components/airthings_ble/translations/id.json new file mode 100644 index 0000000000000000000000000000000000000000..48b138b2e7bdefa0592db00893df40f7727458f1 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "cannot_connect": "Gagal terhubung", + "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Ingin menyiapkan {name}?" + }, + "user": { + "data": { + "address": "Perangkat" + }, + "description": "Pilih perangkat untuk disiapkan" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/it.json b/homeassistant/components/airthings_ble/translations/it.json new file mode 100644 index 0000000000000000000000000000000000000000..90e1ecdeee89d5900fd550fc8dc6d7dc2a26b243 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "cannot_connect": "Impossibile connettersi", + "no_devices_found": "Nessun dispositivo trovato sulla rete", + "unknown": "Errore imprevisto" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Vuoi configurare {name}?" + }, + "user": { + "data": { + "address": "Dispositivo" + }, + "description": "Seleziona un dispositivo da configurare" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/ja.json b/homeassistant/components/airthings_ble/translations/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..07feda5788d30197cbc08bf32ab8532a532786d2 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/ja.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\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", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "{name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "address": "\u30c7\u30d0\u30a4\u30b9" + }, + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/nb.json b/homeassistant/components/airthings_ble/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..469243aed3ddcd3d6c03421ad0e05235d286724c --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/nb.json @@ -0,0 +1,9 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/nl.json b/homeassistant/components/airthings_ble/translations/nl.json new file mode 100644 index 0000000000000000000000000000000000000000..19c9a433f994fd5565fe0008c07f824d4bca82ac --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Nederlands", + "already_in_progress": "Nederlands", + "cannot_connect": "Nederlands", + "no_devices_found": "Nederlands", + "unknown": "Nederlands" + }, + "flow_title": "Nederlands", + "step": { + "bluetooth_confirm": { + "description": "Nederlands" + }, + "user": { + "data": { + "address": "Nederlands" + }, + "description": "Nederlands" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/no.json b/homeassistant/components/airthings_ble/translations/no.json new file mode 100644 index 0000000000000000000000000000000000000000..d23d4703ac33197936af81785bd1875e365ef3f9 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "cannot_connect": "Tilkobling mislyktes", + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", + "unknown": "Uventet feil" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Vil du konfigurere {name}?" + }, + "user": { + "data": { + "address": "Enhet" + }, + "description": "Velg en enhet du vil konfigurere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/pl.json b/homeassistant/components/airthings_ble/translations/pl.json new file mode 100644 index 0000000000000000000000000000000000000000..550f912763589a3b521b2c867f65208a67a24219 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_in_progress": "Konfiguracja jest ju\u017c w toku", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {name}?" + }, + "user": { + "data": { + "address": "Urz\u0105dzenie" + }, + "description": "Wybierz urz\u0105dzenie do skonfigurowania" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/pt-BR.json b/homeassistant/components/airthings_ble/translations/pt-BR.json new file mode 100644 index 0000000000000000000000000000000000000000..bbce7b1219a169481e3061394936f1e9772ce873 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/pt-BR.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", + "cannot_connect": "Falha ao conectar", + "no_devices_found": "Nenhum dispositivo encontrado na rede", + "unknown": "Erro inesperado" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Deseja configurar {name}?" + }, + "user": { + "data": { + "address": "Dispositivo" + }, + "description": "Escolha um dispositivo para configurar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/ru.json b/homeassistant/components/airthings_ble/translations/ru.json new file mode 100644 index 0000000000000000000000000000000000000000..f12ea86e777fe9dd3c67753929c9c994ca7255e6 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name}?" + }, + "user": { + "data": { + "address": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/sv.json b/homeassistant/components/airthings_ble/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..fb65ad157f80279a7291ff73989d454530e4a057 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad", + "already_in_progress": "Konfigurationsfl\u00f6det p\u00e5g\u00e5r redan", + "cannot_connect": "Det gick inte att ansluta.", + "no_devices_found": "Inga enheter hittades i n\u00e4tverket", + "unknown": "Ov\u00e4ntat fel" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Vill du konfigurera {name}?" + }, + "user": { + "data": { + "address": "Enhet" + }, + "description": "V\u00e4lj en enhet att konfigurera" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/tr.json b/homeassistant/components/airthings_ble/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..9854002de334c80a6604f018fec6ab84dba6b7c5 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz 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", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "{name} kurulumunu yapmak istiyor musunuz?" + }, + "user": { + "data": { + "address": "Cihaz" + }, + "description": "Kurulum i\u00e7in bir cihaz se\u00e7in" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/zh-Hans.json b/homeassistant/components/airthings_ble/translations/zh-Hans.json new file mode 100644 index 0000000000000000000000000000000000000000..165d98f5bbd6204919c84b3a3de93d6ef8a318fe --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/zh-Hans.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e", + "already_in_progress": "\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c\u4e2d", + "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5", + "no_devices_found": "\u5728\u6b64\u7f51\u7edc\u4e0a\u672a\u627e\u5230\u8bbe\u5907", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "bluetooth_confirm": { + "description": "\u60a8\u60f3\u8bbe\u7f6e\u7684\u8bbe\u5907\u662f\u5426\u662f\uff1a {name}?" + }, + "user": { + "data": { + "address": "\u8bbe\u5907" + }, + "description": "\u9009\u62e9\u4e00\u4e2a\u8bbe\u5907\u4ee5\u914d\u7f6e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airthings_ble/translations/zh-Hant.json b/homeassistant/components/airthings_ble/translations/zh-Hant.json new file mode 100644 index 0000000000000000000000000000000000000000..749355e8bdf7e4ab32c9c6be7d28b4bc7138bf7f --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name}\uff1f" + }, + "user": { + "data": { + "address": "\u88dd\u7f6e" + }, + "description": "\u9078\u64c7\u6240\u8981\u8a2d\u5b9a\u7684\u88dd\u7f6e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/diagnostics.py b/homeassistant/components/airvisual/diagnostics.py index 94cf5f1899d1f8ae69abe1d64441914fce2b100d..c273dbe7a55a8928512eea1cd6ead0267a2babad 100644 --- a/homeassistant/components/airvisual/diagnostics.py +++ b/homeassistant/components/airvisual/diagnostics.py @@ -5,13 +5,20 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_STATE +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_STATE, + CONF_UNIQUE_ID, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_CITY, CONF_COUNTRY, DOMAIN CONF_COORDINATES = "coordinates" +CONF_TITLE = "title" TO_REDACT = { CONF_API_KEY, @@ -21,6 +28,9 @@ TO_REDACT = { CONF_LATITUDE, CONF_LONGITUDE, CONF_STATE, + # Config entry title and unique ID may contain sensitive data: + CONF_TITLE, + CONF_UNIQUE_ID, } @@ -31,10 +41,6 @@ async def async_get_config_entry_diagnostics( coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] return { - "entry": { - "title": entry.title, - "data": async_redact_data(entry.data, TO_REDACT), - "options": async_redact_data(entry.options, TO_REDACT), - }, + "entry": async_redact_data(entry.as_dict(), TO_REDACT), "data": async_redact_data(coordinator.data["data"], TO_REDACT), } diff --git a/homeassistant/components/airvisual/manifest.json b/homeassistant/components/airvisual/manifest.json index 9a6279f34a67f9d68cf93e1262586da052d2bf93..73bbf0cd5893c56d27822766eec67255bd67afd4 100644 --- a/homeassistant/components/airvisual/manifest.json +++ b/homeassistant/components/airvisual/manifest.json @@ -6,5 +6,6 @@ "requirements": ["pyairvisual==2022.07.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", - "loggers": ["pyairvisual", "pysmb"] + "loggers": ["pyairvisual", "pysmb"], + "integration_type": "device" } diff --git a/homeassistant/components/airvisual/translations/nb.json b/homeassistant/components/airvisual/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..b5a62d8645920f8f8535da8646d7172b86d98698 --- /dev/null +++ b/homeassistant/components/airvisual/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "general_error": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/no.json b/homeassistant/components/airvisual/translations/no.json index d4ca80d480505de3d8733eee0c47130d5323b772..89ff3fca958dbd7a5f9cf22adf88328cabf1f42c 100644 --- a/homeassistant/components/airvisual/translations/no.json +++ b/homeassistant/components/airvisual/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Plasseringen er allerede konfigurert eller Node / Pro ID er allerede registrert.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/airvisual/translations/sensor.he.json b/homeassistant/components/airvisual/translations/sensor.he.json index 5745fb051f66fd0b27e928e8cfb32ff84d1acbed..86d7ee809058b58fd0e23a85858c242fddbcba9d 100644 --- a/homeassistant/components/airvisual/translations/sensor.he.json +++ b/homeassistant/components/airvisual/translations/sensor.he.json @@ -6,7 +6,8 @@ "airvisual__pollutant_level": { "good": "\u05d8\u05d5\u05d1", "unhealthy": "\u05dc\u05d0 \u05d1\u05e8\u05d9\u05d0", - "unhealthy_sensitive": "\u05dc\u05d0 \u05d1\u05e8\u05d9\u05d0 \u05dc\u05e7\u05d1\u05d5\u05e6\u05d5\u05ea \u05e8\u05d2\u05d9\u05e9\u05d5\u05ea" + "unhealthy_sensitive": "\u05dc\u05d0 \u05d1\u05e8\u05d9\u05d0 \u05dc\u05e7\u05d1\u05d5\u05e6\u05d5\u05ea \u05e8\u05d2\u05d9\u05e9\u05d5\u05ea", + "very_unhealthy": "\u05de\u05d0\u05d5\u05d3 \u05dc\u05d0 \u05d1\u05e8\u05d9\u05d0" } } } \ No newline at end of file diff --git a/homeassistant/components/aladdin_connect/translations/no.json b/homeassistant/components/aladdin_connect/translations/no.json index c6e6e8413b326e501af3d0ad0bb489c7ae10b266..464a6bf107156538aa970c5431b12db061bf2aa9 100644 --- a/homeassistant/components/aladdin_connect/translations/no.json +++ b/homeassistant/components/aladdin_connect/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/alarm_control_panel/translations/is.json b/homeassistant/components/alarm_control_panel/translations/is.json index eda11e6177f65bef399738ca31a276052f7f0a50..16c2aeec21da968bf13c0193a6987a230f1c6c1a 100644 --- a/homeassistant/components/alarm_control_panel/translations/is.json +++ b/homeassistant/components/alarm_control_panel/translations/is.json @@ -1,4 +1,18 @@ { + "device_automation": { + "condition_type": { + "is_armed_away": "{entity_name} er \u00e1 ver\u00f0i \u00fati", + "is_armed_home": "{entity_name} er \u00e1 ver\u00f0i heima", + "is_armed_night": "{entity_name} er \u00e1 ver\u00f0i n\u00f3tt", + "is_disarmed": "{entity_name} er ekki \u00e1 ver\u00f0i" + }, + "trigger_type": { + "armed_away": "{entity_name} \u00e1 ver\u00f0i \u00fati", + "armed_home": "\u00e1 ver\u00f0i heima", + "armed_night": "\u00e1 ver\u00f0i n\u00f3tt", + "disarmed": "ekki \u00e1 ver\u00f0i" + } + }, "state": { "_": { "armed": "\u00c1 ver\u00f0i", @@ -6,7 +20,7 @@ "armed_home": "\u00c1 ver\u00f0i heima", "armed_night": "\u00c1 ver\u00f0i n\u00f3tt", "arming": "Set \u00e1 v\u00f6r\u00f0", - "disarmed": "ekki \u00e1 ver\u00f0i", + "disarmed": "Ekki \u00e1 ver\u00f0i", "disarming": "tek af ver\u00f0i", "pending": "B\u00ed\u00f0ur", "triggered": "R\u00e6st" diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 20f3eaf30cac652deb48f8d9303d358360fb01c4..4f8f1dade938357208ca1b3d19c9c5e4539e7109 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -3,8 +3,7 @@ from __future__ import annotations from collections.abc import Callable from datetime import timedelta -import logging -from typing import Any, final +from typing import Any import voluptuous as vol @@ -15,7 +14,6 @@ from homeassistant.components.notify import ( DOMAIN as DOMAIN_NOTIFY, ) from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_ENTITY_ID, CONF_NAME, CONF_REPEAT, @@ -27,10 +25,10 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall -from homeassistant.helpers import service +from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import ( async_track_point_in_time, async_track_state_change_event, @@ -39,39 +37,40 @@ from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import now -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "alert" - -CONF_CAN_ACK = "can_acknowledge" -CONF_NOTIFIERS = "notifiers" -CONF_SKIP_FIRST = "skip_first" -CONF_ALERT_MESSAGE = "message" -CONF_DONE_MESSAGE = "done_message" -CONF_TITLE = "title" -CONF_DATA = "data" - -DEFAULT_CAN_ACK = True -DEFAULT_SKIP_FIRST = False +from .const import ( + CONF_ALERT_MESSAGE, + CONF_CAN_ACK, + CONF_DATA, + CONF_DONE_MESSAGE, + CONF_NOTIFIERS, + CONF_SKIP_FIRST, + CONF_TITLE, + DEFAULT_CAN_ACK, + DEFAULT_SKIP_FIRST, + DOMAIN, + LOGGER, +) ALERT_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Required(CONF_STATE, default=STATE_ON): cv.string, + vol.Optional(CONF_STATE, default=STATE_ON): cv.string, vol.Required(CONF_REPEAT): vol.All( cv.ensure_list, [vol.Coerce(float)], # Minimum delay is 1 second = 0.016 minutes [vol.Range(min=0.016)], ), - vol.Required(CONF_CAN_ACK, default=DEFAULT_CAN_ACK): cv.boolean, - vol.Required(CONF_SKIP_FIRST, default=DEFAULT_SKIP_FIRST): cv.boolean, + vol.Optional(CONF_CAN_ACK, default=DEFAULT_CAN_ACK): cv.boolean, + vol.Optional(CONF_SKIP_FIRST, default=DEFAULT_SKIP_FIRST): cv.boolean, vol.Optional(CONF_ALERT_MESSAGE): cv.template, vol.Optional(CONF_DONE_MESSAGE): cv.template, vol.Optional(CONF_TITLE): cv.template, vol.Optional(CONF_DATA): dict, - vol.Required(CONF_NOTIFIERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NOTIFIERS, default=list): vol.All( + cv.ensure_list, [cv.string] + ), } ) @@ -79,16 +78,11 @@ CONFIG_SCHEMA = vol.Schema( {DOMAIN: cv.schema_with_slug_keys(ALERT_SCHEMA)}, extra=vol.ALLOW_EXTRA ) -ALERT_SERVICE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_ids}) - - -def is_on(hass: HomeAssistant, entity_id: str) -> bool: - """Return if the alert is firing and not acknowledged.""" - return hass.states.is_state(entity_id, STATE_ON) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Alert component.""" + component = EntityComponent[Alert](LOGGER, DOMAIN, hass) + entities: list[Alert] = [] for object_id, cfg in config[DOMAIN].items(): @@ -128,50 +122,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if not entities: return False - async def async_handle_alert_service(service_call: ServiceCall) -> None: - """Handle calls to alert services.""" - alert_ids = await service.async_extract_entity_ids(hass, service_call) - - for alert_id in alert_ids: - for alert in entities: - if alert.entity_id != alert_id: - continue - - alert.async_set_context(service_call.context) - if service_call.service == SERVICE_TURN_ON: - await alert.async_turn_on() - elif service_call.service == SERVICE_TOGGLE: - await alert.async_toggle() - else: - await alert.async_turn_off() - - # Setup service calls - hass.services.async_register( - DOMAIN, - SERVICE_TURN_OFF, - async_handle_alert_service, - schema=ALERT_SERVICE_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_TURN_ON, - async_handle_alert_service, - schema=ALERT_SERVICE_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_TOGGLE, - async_handle_alert_service, - schema=ALERT_SERVICE_SCHEMA, - ) - - for alert in entities: - alert.async_write_ha_state() + component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") + component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on") + component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") + + await component.async_add_entities(entities) return True -class Alert(ToggleEntity): +class Alert(Entity): """Representation of an alert.""" _attr_should_poll = False @@ -227,10 +187,8 @@ class Alert(ToggleEntity): hass, [watched_entity_id], self.watched_entity_change ) - @final # type: ignore[misc] @property - # pylint: disable=overridden-final-method - def state(self) -> str: # type: ignore[override] + def state(self) -> str: """Return the alert status.""" if self._firing: if self._ack: @@ -242,7 +200,7 @@ class Alert(ToggleEntity): """Determine if the alert should start or stop.""" if (to_state := event.data.get("new_state")) is None: return - _LOGGER.debug("Watched entity (%s) has changed", event.data.get("entity_id")) + LOGGER.debug("Watched entity (%s) has changed", event.data.get("entity_id")) if to_state.state == self._alert_state and not self._firing: await self.begin_alerting() if to_state.state != self._alert_state and self._firing: @@ -250,7 +208,7 @@ class Alert(ToggleEntity): async def begin_alerting(self) -> None: """Begin the alert procedures.""" - _LOGGER.debug("Beginning Alert: %s", self._attr_name) + LOGGER.debug("Beginning Alert: %s", self._attr_name) self._ack = False self._firing = True self._next_delay = 0 @@ -264,7 +222,7 @@ class Alert(ToggleEntity): async def end_alerting(self) -> None: """End the alert procedures.""" - _LOGGER.debug("Ending Alert: %s", self._attr_name) + LOGGER.debug("Ending Alert: %s", self._attr_name) if self._cancel is not None: self._cancel() self._cancel = None @@ -288,7 +246,7 @@ class Alert(ToggleEntity): return if not self._ack: - _LOGGER.info("Alerting: %s", self._attr_name) + LOGGER.info("Alerting: %s", self._attr_name) self._send_done_message = True if self._message_template is not None: @@ -301,7 +259,7 @@ class Alert(ToggleEntity): async def _notify_done_message(self) -> None: """Send notification of complete alert.""" - _LOGGER.info("Alerting: %s", self._done_message_template) + LOGGER.info("Alerting: %s", self._done_message_template) self._send_done_message = False if self._done_message_template is None: @@ -313,6 +271,9 @@ class Alert(ToggleEntity): async def _send_notification_message(self, message: Any) -> None: + if not self._notifiers: + return + msg_payload = {ATTR_MESSAGE: message} if self._title_template is not None: @@ -321,7 +282,7 @@ class Alert(ToggleEntity): if self._data: msg_payload[ATTR_DATA] = self._data - _LOGGER.debug(msg_payload) + LOGGER.debug(msg_payload) for target in self._notifiers: await self.hass.services.async_call( @@ -330,13 +291,13 @@ class Alert(ToggleEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Async Unacknowledge alert.""" - _LOGGER.debug("Reset Alert: %s", self._attr_name) + LOGGER.debug("Reset Alert: %s", self._attr_name) self._ack = False self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Async Acknowledge alert.""" - _LOGGER.debug("Acknowledged Alert: %s", self._attr_name) + LOGGER.debug("Acknowledged Alert: %s", self._attr_name) self._ack = True self.async_write_ha_state() diff --git a/homeassistant/components/alert/const.py b/homeassistant/components/alert/const.py new file mode 100644 index 0000000000000000000000000000000000000000..e8afd5ab4520c973c12214fd6a73902b618c882a --- /dev/null +++ b/homeassistant/components/alert/const.py @@ -0,0 +1,19 @@ +"""Constants for the Alert integration.""" + +import logging +from typing import Final + +DOMAIN: Final = "alert" + +LOGGER = logging.getLogger(__package__) + +CONF_CAN_ACK = "can_acknowledge" +CONF_NOTIFIERS = "notifiers" +CONF_SKIP_FIRST = "skip_first" +CONF_ALERT_MESSAGE = "message" +CONF_DONE_MESSAGE = "done_message" +CONF_TITLE = "title" +CONF_DATA = "data" + +DEFAULT_CAN_ACK = True +DEFAULT_SKIP_FIRST = False diff --git a/homeassistant/components/alert/manifest.json b/homeassistant/components/alert/manifest.json index bf9724ec2b996512aed7f0ce3dc570f3b7b31c81..c2cdf20f54be89bd16e8786f7e12c0f803ce89e9 100644 --- a/homeassistant/components/alert/manifest.json +++ b/homeassistant/components/alert/manifest.json @@ -3,7 +3,7 @@ "name": "Alert", "documentation": "https://www.home-assistant.io/integrations/alert", "after_dependencies": ["notify"], - "codeowners": ["@home-assistant/core"], + "codeowners": ["@home-assistant/core", "@frenck"], "quality_scale": "internal", "iot_class": "local_push" } diff --git a/homeassistant/components/alert/reproduce_state.py b/homeassistant/components/alert/reproduce_state.py index 49658ab249531f4b56024cb1e72b339cb82d21ff..1e813768b3afe410190296d9207fdc3e43c6396e 100644 --- a/homeassistant/components/alert/reproduce_state.py +++ b/homeassistant/components/alert/reproduce_state.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Iterable -import logging from typing import Any from homeassistant.const import ( @@ -15,9 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, State -from . import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, LOGGER VALID_STATES = {STATE_ON, STATE_OFF} @@ -31,11 +28,11 @@ async def _async_reproduce_state( ) -> None: """Reproduce a single state.""" if (cur_state := hass.states.get(state.entity_id)) is None: - _LOGGER.warning("Unable to find entity %s", state.entity_id) + LOGGER.warning("Unable to find entity %s", state.entity_id) return if state.state not in VALID_STATES: - _LOGGER.warning( + LOGGER.warning( "Invalid state specified for %s: %s", state.entity_id, state.state ) return diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 522cde2a95f890f62e8db1b710fb68109c8aff48..98aed91a941e60439b864fb153e0a078a8260c4b 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CURRENCY_DOLLAR, ENERGY_KILO_WATT_HOUR +from homeassistant.const import CURRENCY_DOLLAR, ENERGY_KILO_WATT_HOUR, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -247,7 +247,7 @@ async def async_setup_entry( renewables_description = SensorEntityDescription( key="renewables", name=f"{entry.title} - Renewables", - native_unit_of_measurement="%", + native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:solar-power", ) diff --git a/homeassistant/components/amberelectric/translations/nb.json b/homeassistant/components/amberelectric/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..4518f3cd8cb8990549d040876ed38f1c26b6c848 --- /dev/null +++ b/homeassistant/components/amberelectric/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown_error": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amberelectric/translations/nl.json b/homeassistant/components/amberelectric/translations/nl.json index 11f0576496e92e586bb7e650e2b027814a9f734c..a86af47ebf9ae0e12a4ed6341d5f6daca66acb08 100644 --- a/homeassistant/components/amberelectric/translations/nl.json +++ b/homeassistant/components/amberelectric/translations/nl.json @@ -2,6 +2,7 @@ "config": { "error": { "invalid_api_token": "Ongeldige API-sleutel", + "no_site": "Geen site opgegeven", "unknown_error": "Onverwachte fout" }, "step": { diff --git a/homeassistant/components/amberelectric/translations/sv.json b/homeassistant/components/amberelectric/translations/sv.json index fdf3161483fa6f06d2f6f5e309999528deebddfe..ec8a2deadd838ce8b7be4dc63f83342cfddea535 100644 --- a/homeassistant/components/amberelectric/translations/sv.json +++ b/homeassistant/components/amberelectric/translations/sv.json @@ -1,5 +1,10 @@ { "config": { + "error": { + "invalid_api_token": "Ogiltig API-nyckel", + "no_site": "Ingen plats har tillhandah\u00e5llits.", + "unknown_error": "Ov\u00e4ntat fel" + }, "step": { "site": { "data": { diff --git a/homeassistant/components/ambient_station/diagnostics.py b/homeassistant/components/ambient_station/diagnostics.py index 6005b206954b5781452808026d4d0b2a09846272..d18047fe8e40397e551bfa481e17cb75c518a04b 100644 --- a/homeassistant/components/ambient_station/diagnostics.py +++ b/homeassistant/components/ambient_station/diagnostics.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LOCATION +from homeassistant.const import CONF_API_KEY, CONF_LOCATION, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from . import AmbientStation @@ -16,6 +16,7 @@ CONF_APP_KEY_CAMEL = "appKey" CONF_DEVICE_ID_CAMEL = "deviceId" CONF_MAC_ADDRESS = "mac_address" CONF_MAC_ADDRESS_CAMEL = "macAddress" +CONF_TITLE = "title" CONF_TZ = "tz" TO_REDACT = { @@ -28,6 +29,9 @@ TO_REDACT = { CONF_MAC_ADDRESS, CONF_MAC_ADDRESS_CAMEL, CONF_TZ, + # Config entry title and unique ID may contain sensitive data: + CONF_TITLE, + CONF_UNIQUE_ID, } @@ -38,9 +42,6 @@ async def async_get_config_entry_diagnostics( ambient: AmbientStation = hass.data[DOMAIN][entry.entry_id] return { - "entry": { - "title": entry.title, - "data": async_redact_data(entry.data, TO_REDACT), - }, + "entry": async_redact_data(entry.as_dict(), TO_REDACT), "stations": async_redact_data(ambient.stations, TO_REDACT), } diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 21f7e25126946efe24dc311b81a2a57346903d9e..473958f680d9eaa2912f418087c44339e4397cbe 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -6,5 +6,6 @@ "requirements": ["aioambient==2021.11.0"], "codeowners": ["@bachya"], "iot_class": "cloud_push", - "loggers": ["aioambient"] + "loggers": ["aioambient"], + "integration_type": "hub" } diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 65c726bfff3ba84e59ccf27880578f67505334a8..b1e98261c6ebd8796781ef2c9de8f6fc606ae145 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -19,10 +19,10 @@ from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, PRECIPITATION_INCHES, - PRECIPITATION_INCHES_PER_HOUR, PRESSURE_INHG, SPEED_MILES_PER_HOUR, TEMP_FAHRENHEIT, + UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityDescription @@ -194,9 +194,9 @@ SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=TYPE_HOURLYRAININ, name="Hourly rain rate", - icon="mdi:water", - native_unit_of_measurement=PRECIPITATION_INCHES_PER_HOUR, + native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key=TYPE_HUMIDITY10, diff --git a/homeassistant/components/amp_motorization/manifest.json b/homeassistant/components/amp_motorization/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..c8f6f935a2445fe96124b0ac3488e48da92fcca2 --- /dev/null +++ b/homeassistant/components/amp_motorization/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "amp_motorization", + "name": "AMP Motorization", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index f7bdb303eb7080145d30da3fdb7989b19bdfd8ac..bdc7806e45694d8fde1f4896d7e5c6f0f8051268 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -1,4 +1,6 @@ """Send instance and usage analytics.""" +from typing import Any + import voluptuous as vol from homeassistant.components import websocket_api @@ -41,7 +43,7 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: async def websocket_analytics( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Return analytics preferences.""" analytics: Analytics = hass.data[DOMAIN] @@ -62,7 +64,7 @@ async def websocket_analytics( async def websocket_analytics_preferences( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Update analytics preferences.""" preferences = msg[ATTR_PREFERENCES] diff --git a/homeassistant/components/android_ip_webcam/__init__.py b/homeassistant/components/android_ip_webcam/__init__.py index d792b42d4bfdbc204900e02453291a36e53c3ecb..47307fb3690f8de8476e49a0bfab9f97ae0bf4f6 100644 --- a/homeassistant/components/android_ip_webcam/__init__.py +++ b/homeassistant/components/android_ip_webcam/__init__.py @@ -2,37 +2,20 @@ from __future__ import annotations from pydroid_ipcam import PyDroidIPCam -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_SENSORS, - CONF_SWITCHES, - CONF_TIMEOUT, CONF_USERNAME, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_MOTION_SENSOR, - DEFAULT_NAME, - DEFAULT_PORT, - DEFAULT_TIMEOUT, - DOMAIN, - SCAN_INTERVAL, - SENSORS, - SWITCHES, -) +from .const import DOMAIN from .coordinator import AndroidIPCamDataUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -43,66 +26,7 @@ PLATFORMS: list[Platform] = [ ] -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional( - CONF_TIMEOUT, default=DEFAULT_TIMEOUT - ): cv.positive_int, - vol.Optional( - CONF_SCAN_INTERVAL, default=SCAN_INTERVAL - ): cv.time_period, - vol.Inclusive(CONF_USERNAME, "authentication"): cv.string, - vol.Inclusive(CONF_PASSWORD, "authentication"): cv.string, - vol.Optional(CONF_SWITCHES): vol.All( - cv.ensure_list, [vol.In(SWITCHES)] - ), - vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [vol.In(SENSORS)] - ), - vol.Optional(CONF_MOTION_SENSOR): cv.boolean, - } - ) - ], - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the IP Webcam component.""" - - if DOMAIN not in config: - return True - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.11.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - 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, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/android_ip_webcam/config_flow.py b/homeassistant/components/android_ip_webcam/config_flow.py index c41a998ff545c04cdab5a3ee188f21ccfcff6e39..2a26292fdd706656c984895a423b65a84b6facd6 100644 --- a/homeassistant/components/android_ip_webcam/config_flow.py +++ b/homeassistant/components/android_ip_webcam/config_flow.py @@ -8,15 +8,7 @@ from pydroid_ipcam.exceptions import PyDroidIPCamException, Unauthorized import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_SCAN_INTERVAL, - CONF_TIMEOUT, - CONF_USERNAME, -) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv @@ -75,19 +67,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match( {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} ) - # to be removed when YAML import is removed - title = user_input.get(CONF_NAME) or user_input[CONF_HOST] if not (errors := await validate_input(self.hass, user_input)): - return self.async_create_entry(title=title, data=user_input) + return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors, ) - - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - import_config.pop(CONF_SCAN_INTERVAL) - import_config.pop(CONF_TIMEOUT) - return await self.async_step_user(import_config) diff --git a/homeassistant/components/android_ip_webcam/const.py b/homeassistant/components/android_ip_webcam/const.py index 6672628d97777d3d98cdc3c02ba736cf3428215b..c4a6ab2fa23ed1f4d992c1a6a7772d35d8d4126b 100644 --- a/homeassistant/components/android_ip_webcam/const.py +++ b/homeassistant/components/android_ip_webcam/const.py @@ -4,38 +4,6 @@ from datetime import timedelta from typing import Final DOMAIN: Final = "android_ip_webcam" -DEFAULT_NAME: Final = "IP Webcam" DEFAULT_PORT: Final = 8080 -DEFAULT_TIMEOUT: Final = 10 - -CONF_MOTION_SENSOR: Final = "motion_sensor" - MOTION_ACTIVE: Final = "motion_active" SCAN_INTERVAL: Final = timedelta(seconds=10) - - -SWITCHES = [ - "exposure_lock", - "ffc", - "focus", - "gps_active", - "motion_detect", - "night_vision", - "overlay", - "torch", - "whitebalance_lock", - "video_recording", -] - -SENSORS = [ - "audio_connections", - "battery_level", - "battery_temp", - "battery_voltage", - "light", - "motion", - "pressure", - "proximity", - "sound", - "video_connections", -] diff --git a/homeassistant/components/android_ip_webcam/translations/he.json b/homeassistant/components/android_ip_webcam/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..7d1847cdf4be0e9d4b1282777b9a7ab863357b09 --- /dev/null +++ b/homeassistant/components/android_ip_webcam/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "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" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/android_ip_webcam/translations/id.json b/homeassistant/components/android_ip_webcam/translations/id.json index 593fa61dea333f9356f8f5da046eda98597285ef..d84e0a40fad4149ceb44ece9c4bb51e7a81ed3f9 100644 --- a/homeassistant/components/android_ip_webcam/translations/id.json +++ b/homeassistant/components/android_ip_webcam/translations/id.json @@ -20,8 +20,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Proses konfigurasi Android IP Webcam lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Android IP Webcam dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Android IP Webcam dalam proses penghapusan" + "description": "Proses konfigurasi Integrasi Android IP Webcam lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Integrasi Android IP Webcam dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Android IP Webcam dalam proses penghapusan" } } } \ No newline at end of file diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 92d4f806b390c2ba4e26b8db589950ab0e360533..6b1f5669345394e5bc7f687bc1f38a14b4c0924f 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/androidtv", "requirements": [ "adb-shell[async]==0.4.3", - "androidtv[async]==0.0.67", + "androidtv[async]==0.0.69", "pure-python-adb[async]==0.3.0.dev0" ], "codeowners": ["@JeffLIrion", "@ollo69"], diff --git a/homeassistant/components/androidtv/translations/nb.json b/homeassistant/components/androidtv/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/androidtv/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/anthemav/config_flow.py b/homeassistant/components/anthemav/config_flow.py index 0e878bcc9133eb34cd9033962bbf47b9e4dcaa2d..23694654eb3a6a74f1f3f50450aa25014ebd2704 100644 --- a/homeassistant/components/anthemav/config_flow.py +++ b/homeassistant/components/anthemav/config_flow.py @@ -92,9 +92,3 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - - async def async_step_import( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(user_input) diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 0c5837e154e6f789347c2cff5fc84ada3305d10a..2ab23ff2d37b6b1e18a61de3e74785d3608de1dc 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -5,72 +5,23 @@ import logging from anthemav.connection import Connection from anthemav.protocol import AVR -import voluptuous as vol from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from .const import ( - ANTHEMAV_UDATE_SIGNAL, - CONF_MODEL, - DEFAULT_NAME, - DEFAULT_PORT, - DOMAIN, - MANUFACTURER, -) - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } -) +from .const import ANTHEMAV_UDATE_SIGNAL, CONF_MODEL, DOMAIN, MANUFACTURER -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up our socket to the AVR.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.10.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - _LOGGER.warning( - "Configuration of the Anthem A/V Receivers integration in YAML is " - "deprecated and will be removed in Home Assistant 2022.10; Your " - "existing configuration has been imported into the UI automatically " - "and can be safely removed from your configuration.yaml file" - ) - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( diff --git a/homeassistant/components/anthemav/strings.json b/homeassistant/components/anthemav/strings.json index b4e777c4de156966c30d6c15b26fa4affce7f72d..1f1dd0ec75b49935b6e7f649c58b8ab73fcadf3a 100644 --- a/homeassistant/components/anthemav/strings.json +++ b/homeassistant/components/anthemav/strings.json @@ -15,11 +15,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Anthem A/V Receivers YAML configuration is being removed", - "description": "Configuring Anthem A/V Receivers using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Anthem A/V Receivers YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/anthemav/translations/en.json b/homeassistant/components/anthemav/translations/en.json index af4c83eb2a8814c35724f9a7d740fd90e9b478eb..9177d5a6e7006c259463181bf1e589c72e05d559 100644 --- a/homeassistant/components/anthemav/translations/en.json +++ b/homeassistant/components/anthemav/translations/en.json @@ -15,11 +15,5 @@ } } } - }, - "issues": { - "deprecated_yaml": { - "description": "Configuring Anthem A/V Receivers using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Anthem A/V Receivers YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", - "title": "The Anthem A/V Receivers YAML configuration is being removed" - } } } \ No newline at end of file diff --git a/homeassistant/components/anthemav/translations/id.json b/homeassistant/components/anthemav/translations/id.json index 8c7e40b4c0bd0283e5b655eb1e73be968c2679ca..99843443ab9e5b005608e4b665a87742850412fb 100644 --- a/homeassistant/components/anthemav/translations/id.json +++ b/homeassistant/components/anthemav/translations/id.json @@ -18,8 +18,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Proses konfigurasi Receiver Anthem A/V lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Receiver Anthem A/V dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Anthem A/V Receiver dalam proses penghapusan" + "description": "Proses konfigurasi Integrasi Receiver Anthem A/V lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Integrasi Receiver Anthem A/V dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Anthem A/V Receiver dalam proses penghapusan" } } } \ No newline at end of file diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 5f76e6c1afa0e0fdf86357fc04959e3862ded5f1..b55f672264d32bfc064ff060d746ccb9ae93bc57 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -172,6 +172,11 @@ SENSORS: dict[str, SensorEntityDescription] = { native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), + "laststest": SensorEntityDescription( + key="laststest", + name="UPS Last Self Test", + icon="mdi:calendar-clock", + ), "lastxfer": SensorEntityDescription( key="lastxfer", name="UPS Last Transfer", @@ -331,8 +336,8 @@ SENSORS: dict[str, SensorEntityDescription] = { ), "selftest": SensorEntityDescription( key="selftest", - name="UPS Last Self Test", - icon="mdi:calendar-clock", + name="UPS Self Test result", + icon="mdi:information-outline", ), "sense": SensorEntityDescription( key="sense", diff --git a/homeassistant/components/apcupsd/translations/bg.json b/homeassistant/components/apcupsd/translations/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..0160e0fee552ea6203b1377dbcc3f54391ffda48 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/bg.json @@ -0,0 +1,23 @@ +{ + "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" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 APC UPS Daemon \u0441\u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u0432\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/ca.json b/homeassistant/components/apcupsd/translations/ca.json new file mode 100644 index 0000000000000000000000000000000000000000..3c890da936b85f7e8244abde434b29e4b25ed79e --- /dev/null +++ b/homeassistant/components/apcupsd/translations/ca.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "no_status": "Amfitri\u00f3 no ha informat d'estat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "port": "Port" + }, + "description": "Introdueix l'amfitri\u00f3 i el port en qu\u00e8 s'est\u00e0 servint apcupsd NIS." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "La configuraci\u00f3 d'APC UPS Deamon mitjan\u00e7ant YAML s'est\u00e0 eliminant.\n\nLa configuraci\u00f3 YAML existent s'ha importat autom\u00e0ticament a la interf\u00edcie d'usuari.\n\nElimina la configuraci\u00f3 YAML d'APC UPS Deamon del fitxer configuration.yaml i reinicia Home Assistant per solucionar aquest problema.", + "title": "La configuraci\u00f3 YAML d'APC UPS Deamon est\u00e0 sent eliminada" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/de.json b/homeassistant/components/apcupsd/translations/de.json new file mode 100644 index 0000000000000000000000000000000000000000..cc410a8c84c6aa36b7cc94e4cd9c4cb21aaa6599 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/de.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "no_status": "Von Host wird kein Status gemeldet" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Gib den Host und den Port ein, auf dem das apcupsd-NIS bereitgestellt wird." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Die Konfiguration von APC UPS Daemon mit YAML wird entfernt. \n\nDeine vorhandene YAML-Konfiguration wurde automatisch in die Benutzeroberfl\u00e4che importiert. \n\nEntferne die APC UPS Daemon YAML-Konfiguration aus deiner configuration.yaml-Datei und starte Home Assistant neu, um dieses Problem zu beheben.", + "title": "Die YAML-Konfiguration des APC UPS Daemon wird entfernt" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/el.json b/homeassistant/components/apcupsd/translations/el.json new file mode 100644 index 0000000000000000000000000000000000000000..a7925a9e8145c145a5411446c4ef73b05b58cbeb --- /dev/null +++ b/homeassistant/components/apcupsd/translations/el.json @@ -0,0 +1,26 @@ +{ + "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", + "no_status": "\u0394\u03b5\u03bd \u03b1\u03bd\u03b1\u03c6\u03ad\u03c1\u03b5\u03c4\u03b1\u03b9 \u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7 \u03b1\u03c0\u03cc \u039a\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc\u03c2 \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae\u03c2" + }, + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2" + }, + "step": { + "user": { + "data": { + "host": "\u039a\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc\u03c2 \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae\u03c2", + "port": "\u0398\u03cd\u03c1\u03b1" + }, + "description": "\u0395\u03b9\u03c3\u03b1\u03b3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf\u03bd \u03ba\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae \u03ba\u03b1\u03b9 \u03c4\u03b7 \u03b8\u03cd\u03c1\u03b1 \u03c3\u03c4\u03b7\u03bd \u03bf\u03c0\u03bf\u03af\u03b1 \u03b5\u03be\u03c5\u03c0\u03b7\u03c1\u03b5\u03c4\u03b5\u03af\u03c4\u03b1\u03b9 \u03c4\u03bf apcupsd NIS." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "\u0397 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03c4\u03bf\u03c5 APC UPS Daemon \u03bc\u03b5 \u03c7\u03c1\u03ae\u03c3\u03b7 YAML \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03b5\u03af\u03c4\u03b1\u03b9. \n\n \u0397 \u03c5\u03c0\u03ac\u03c1\u03c7\u03bf\u03c5\u03c3\u03b1 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03ae \u03c3\u03b1\u03c2 YAML \u03ad\u03c7\u03b5\u03b9 \u03b5\u03b9\u03c3\u03b1\u03c7\u03b8\u03b5\u03af \u03b1\u03c5\u03c4\u03cc\u03bc\u03b1\u03c4\u03b1 \u03c3\u03c4\u03b7 \u03b4\u03b9\u03b5\u03c0\u03b1\u03c6\u03ae \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7. \n\n \u039a\u03b1\u03c4\u03b1\u03c1\u03b3\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 APC UPS Daemon YAML \u03b1\u03c0\u03cc \u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf configuration.yaml \u03ba\u03b1\u03b9 \u03b5\u03c0\u03b1\u03bd\u03b5\u03ba\u03ba\u03b9\u03bd\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf Home Assistant \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03bf\u03c1\u03b8\u03ce\u03c3\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u03c0\u03c1\u03cc\u03b2\u03bb\u03b7\u03bc\u03b1.", + "title": "\u0397 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 APC UPS Daemon YAML \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03b5\u03af\u03c4\u03b1\u03b9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/es.json b/homeassistant/components/apcupsd/translations/es.json new file mode 100644 index 0000000000000000000000000000000000000000..6f8efa27eae5173c4ffc076d2249bcb3cfd817fe --- /dev/null +++ b/homeassistant/components/apcupsd/translations/es.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "no_status": "No se informa ning\u00fan estado del Host" + }, + "error": { + "cannot_connect": "No se pudo conectar" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Puerto" + }, + "description": "Introduce el host y el puerto en el que se sirve el NIS apcupsd." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Se va a eliminar la configuraci\u00f3n de APC UPS Daemon mediante YAML. \n\nTu configuraci\u00f3n YAML existente se ha importado a la IU autom\u00e1ticamente. \n\nElimina la configuraci\u00f3n YAML de APC UPS Daemon de tu archivo configuration.yaml y reinicia Home Assistant para solucionar este problema.", + "title": "Se va a eliminar la configuraci\u00f3n YAML de APC UPS Daemon" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/et.json b/homeassistant/components/apcupsd/translations/et.json new file mode 100644 index 0000000000000000000000000000000000000000..253c963950fb20a979f7689e8c37c8b914e0c62f --- /dev/null +++ b/homeassistant/components/apcupsd/translations/et.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "no_status": "Host ei ole staatust teatatud." + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Sisesta host ja port millel apcupsd NIS-i teenindatakse." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "APC UPS Daemon'i konfigureerimine YAML-i abil eemaldatakse.\n\nTeie olemasolev YAML-konfiguratsioon on automaatselt kasutajaliidesesse imporditud.\n\nProbleemi lahendamiseks eemaldage APC UPS Daemon YAML-konfiguratsioon failist configuration.yaml ja k\u00e4ivitage Home Assistant uuesti.", + "title": "APC UPS Daemon YAML konfiguratsioon eemaldatakse" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/fr.json b/homeassistant/components/apcupsd/translations/fr.json new file mode 100644 index 0000000000000000000000000000000000000000..a60eb14fafd098c143dbbdd21ec728414087a758 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "no_status": "Aucun \u00e9tat n'est signal\u00e9 par H\u00f4te" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/he.json b/homeassistant/components/apcupsd/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..c3a67844fdd6ea5566574af15ecfb7146a429079 --- /dev/null +++ b/homeassistant/components/apcupsd/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": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/hu.json b/homeassistant/components/apcupsd/translations/hu.json new file mode 100644 index 0000000000000000000000000000000000000000..365050da78f469a151dce441f5b61723cf97c15b --- /dev/null +++ b/homeassistant/components/apcupsd/translations/hu.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "no_status": "Nincs \u00e1llapotjelent\u00e9s" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "step": { + "user": { + "data": { + "host": "C\u00edm", + "port": "Port" + }, + "description": "Adja meg azt az g\u00e9p c\u00edm\u00e9t \u00e9s a portot, amelyen az apcupsd fut." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Az APC UPS d\u00e9mon konfigur\u00e1l\u00e1sa YAML haszn\u00e1lat\u00e1val elt\u00e1vol\u00edt\u00e1sra ker\u00fcl.\n\nA megl\u00e9v\u0151 YAML konfigur\u00e1ci\u00f3 automatikusan import\u00e1l\u00e1sra ker\u00fclt a felhaszn\u00e1l\u00f3i fel\u00fcletre.\n\nA probl\u00e9ma megold\u00e1s\u00e1hoz t\u00e1vol\u00edtsa el az APC UPS Daemon YAML konfigur\u00e1ci\u00f3t a configuration.yaml f\u00e1jlb\u00f3l, \u00e9s ind\u00edtsa \u00fajra a Home Assistantot.", + "title": "Az APC UPS Daemon YAML konfigur\u00e1ci\u00f3ja elt\u00e1vol\u00edt\u00e1sra ker\u00fcl" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/id.json b/homeassistant/components/apcupsd/translations/id.json new file mode 100644 index 0000000000000000000000000000000000000000..e6183aff4e20ebaede2e6aa0a8a774eb58b2fd75 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/id.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "no_status": "Tidak ada status yang dilaporkan dari Host" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Masukkan host dan port tempat NIS apcupsd dilayani." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Proses konfigurasi Integrasi APC UPS Daemon lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Integrasi APC UPS Daemon dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi Integrasi YAML APC UPS Daemon dalam proses penghapusan" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/it.json b/homeassistant/components/apcupsd/translations/it.json new file mode 100644 index 0000000000000000000000000000000000000000..ad68159e956e6730ade0dd170c97eb2145a9de24 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/it.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "no_status": "Nessuno stato viene segnalato da Host" + }, + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Porta" + }, + "description": "Immettere l'host e la porta su cui viene servito il NIS apcupsd." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "La configurazione del demone APC UPS tramite YAML \u00e8 stata rimossa. \n\nLa tua configurazione YAML esistente \u00e8 stata importata automaticamente nell'interfaccia utente. \n\nRimuovere la configurazione YAML di APC UPS Daemon dal file configuration.yaml e riavviare Home Assistant per risolvere questo problema.", + "title": "La configurazione YAML di APC UPS Daemon \u00e8 stata rimossa" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/ja.json b/homeassistant/components/apcupsd/translations/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..217f813b894df20e8ecd18c59e7b9a4390508b13 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/ja.json @@ -0,0 +1,18 @@ +{ + "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": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/nb.json b/homeassistant/components/apcupsd/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d805bfdee74b07fed1f9b0d1bd76798d84b75d29 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/nb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "no_status": "Ingen status er rapportert fra " + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "step": { + "user": { + "data": { + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/nl.json b/homeassistant/components/apcupsd/translations/nl.json new file mode 100644 index 0000000000000000000000000000000000000000..622bb5180f93635c9487aff0a14aec9accbdd54b --- /dev/null +++ b/homeassistant/components/apcupsd/translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "no_status": "Er is geen status gerapporteerd van Host" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Poort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/no.json b/homeassistant/components/apcupsd/translations/no.json new file mode 100644 index 0000000000000000000000000000000000000000..16b7b768f32fc55704c97712d2f6acc2fe5a1ed7 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/no.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "no_status": "Ingen status er rapportert fra Vert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "port": "Port" + }, + "description": "Angi verten og porten som apcupsd NIS blir servert p\u00e5." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Konfigurering av APC UPS Daemon ved hjelp av YAML blir fjernet. \n\n Din eksisterende YAML-konfigurasjon har blitt importert til brukergrensesnittet automatisk. \n\n Fjern APC UPS Daemon YAML-konfigurasjonen fra configuration.yaml-filen og start Home Assistant p\u00e5 nytt for \u00e5 fikse dette problemet.", + "title": "APC UPS Daemon YAML-konfigurasjonen blir fjernet" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/pl.json b/homeassistant/components/apcupsd/translations/pl.json new file mode 100644 index 0000000000000000000000000000000000000000..a7a712854eac1fc031a7434ecd85f4789f725bef --- /dev/null +++ b/homeassistant/components/apcupsd/translations/pl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "no_status": "Nazwa hosta lub adres IP nie zg\u0142asza \u017cadnego statusu" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "port": "Port" + }, + "description": "Wprowad\u017a nazw\u0119 hosta i port, na kt\u00f3rym jest obs\u0142ugiwany apcupsd NIS." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Konfiguracja APC UPS Daemon przy u\u017cyciu YAML zostanie usuni\u0119ta. \n\nTwoja istniej\u0105ca konfiguracja YAML zosta\u0142a automatycznie zaimportowana do interfejsu u\u017cytkownika. \n\nUsu\u0144 konfiguracj\u0119 YAML z pliku configuration.yaml i uruchom ponownie Home Assistanta, aby rozwi\u0105za\u0107 ten problem.", + "title": "Konfiguracja YAML dla APC UPS Daemon zostanie usuni\u0119ta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/pt-BR.json b/homeassistant/components/apcupsd/translations/pt-BR.json new file mode 100644 index 0000000000000000000000000000000000000000..44c7673489cf906bd0c96a0c28d56164ee02e3c5 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/pt-BR.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", + "no_status": "Nenhum status \u00e9 relatado de Nome do host" + }, + "error": { + "cannot_connect": "Falha ao conectar" + }, + "step": { + "user": { + "data": { + "host": "Nome do host", + "port": "Porta" + }, + "description": "Insira o host e a porta em que o NIS apcupsd est\u00e1 sendo servido." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "A configura\u00e7\u00e3o do UPS Daemon da APC usando YAML est\u00e1 sendo removida. \n\n Sua configura\u00e7\u00e3o YAML existente foi importada para a interface do usu\u00e1rio automaticamente. \n\n Remova a configura\u00e7\u00e3o APC UPS Daemon YAML do arquivo configuration.yaml e reinicie o Home Assistant para corrigir esse problema.", + "title": "A configura\u00e7\u00e3o YAML de APC UPS Daemon est\u00e1 sendo removida" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/ru.json b/homeassistant/components/apcupsd/translations/ru.json new file mode 100644 index 0000000000000000000000000000000000000000..a73c29d265acb7567351a655bd749f7635bca24e --- /dev/null +++ b/homeassistant/components/apcupsd/translations/ru.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "no_status": "\u0425\u043e\u0441\u0442 \u043d\u0435 \u0441\u043e\u043e\u0431\u0449\u0430\u0435\u0442 \u043e \u0441\u0432\u043e\u0451\u043c \u0441\u0442\u0430\u0442\u0443\u0441\u0435." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u0445\u043e\u0441\u0442\u0435, \u043d\u0430 \u043a\u043e\u0442\u043e\u0440\u043e\u043c \u0437\u0430\u043f\u0443\u0449\u0435\u043d apcupsd NIS." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "\u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 APC UPS Daemon \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e YAML \u0431\u0443\u0434\u0435\u0442 \u0443\u0434\u0430\u043b\u0435\u043d\u0430.\n\n\u0412\u0430\u0448\u0430 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0430\u044f YAML-\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0430. \u0423\u0434\u0430\u043b\u0438\u0442\u0435 \u0435\u0451 \u0438\u0437 \u0444\u0430\u0439\u043b\u0430 configuration.yaml \u0438 \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u0435 Home Assistant, \u0447\u0442\u043e\u0431\u044b \u0443\u0441\u0442\u0440\u0430\u043d\u0438\u0442\u044c \u044d\u0442\u0443 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443.", + "title": "\u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 APC UPS Daemon \u0447\u0435\u0440\u0435\u0437 YAML \u0431\u0443\u0434\u0435\u0442 \u0443\u0434\u0430\u043b\u0435\u043d\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/sv.json b/homeassistant/components/apcupsd/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..3cfc7b962606e4879d5c975ac8a0892c359d9a69 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/sv.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad", + "no_status": "Ingen status rapporteras fr\u00e5n v\u00e4rd" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta." + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "port": "Port" + }, + "description": "Ange den v\u00e4rd och port som apcupsd NIS anv\u00e4nds p\u00e5." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Konfigurering av APC UPS Daemon med YAML tas bort. \n\n Din befintliga YAML-konfiguration har automatiskt importerats till anv\u00e4ndargr\u00e4nssnittet. \n\n Ta bort APC UPS Daemon YAML-konfigurationen fr\u00e5n filen configuration.yaml och starta om Home Assistant f\u00f6r att \u00e5tg\u00e4rda problemet.", + "title": "APC UPS Daemon YAML-konfigurationen tas bort" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/tr.json b/homeassistant/components/apcupsd/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..cae36e5752ffa5c39072a065c6a009092bcd1a33 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/tr.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "no_status": "Sunucu herhangi bir durum bildirilmedi" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "step": { + "user": { + "data": { + "host": "Sunucu", + "port": "Port" + }, + "description": "apcupsd NIS'nin sunuldu\u011fu ana bilgisayar\u0131 ve ba\u011flant\u0131 noktas\u0131n\u0131 girin." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "APC UPS Daemon'un YAML kullan\u0131larak yap\u0131land\u0131r\u0131lmas\u0131 kald\u0131r\u0131l\u0131yor. \n\n Mevcut YAML yap\u0131land\u0131rman\u0131z otomatik olarak kullan\u0131c\u0131 aray\u00fcz\u00fcne aktar\u0131ld\u0131. \n\n APC UPS Daemon YAML yap\u0131land\u0131rmas\u0131n\u0131 configuration.yaml dosyan\u0131zdan kald\u0131r\u0131n ve bu sorunu gidermek i\u00e7in Home Assistant'\u0131 yeniden ba\u015flat\u0131n.", + "title": "APC UPS Daemon YAML yap\u0131land\u0131rmas\u0131 kald\u0131r\u0131l\u0131yor" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/zh-Hans.json b/homeassistant/components/apcupsd/translations/zh-Hans.json new file mode 100644 index 0000000000000000000000000000000000000000..60a57c849ef0bc6a105a4ff10be9220b5934e218 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/zh-Hans.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e" + }, + "error": { + "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a", + "port": "\u7aef\u53e3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apcupsd/translations/zh-Hant.json b/homeassistant/components/apcupsd/translations/zh-Hant.json new file mode 100644 index 0000000000000000000000000000000000000000..015ba0f7797df7e7ff0e20b60e9fbc10dc6fc4d7 --- /dev/null +++ b/homeassistant/components/apcupsd/translations/zh-Hant.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "no_status": "\u4e3b\u6a5f\u7aef \u672a\u56de\u5831\u4efb\u4f55\u72c0\u614b" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0" + }, + "description": "\u8f38\u5165 apcupsd NIS \u670d\u52d9\u4e3b\u6a5f\u8207\u901a\u8a0a\u57e0\u3002" + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "\u4f7f\u7528 YAML \u8a2d\u5b9a\u7684 APC UPS Daemon \u5373\u5c07\u9032\u884c\u79fb\u9664\u3002\n\n\u65e2\u6709\u7684 YAML \u8a2d\u5b9a\u5c07\u81ea\u52d5\u532f\u5165\u81f3 UI \u5167\u3002\n\n\u8acb\u65bc configuration.yaml \u6a94\u6848\u4e2d\u79fb\u9664 APC UPS Daemon YAML \u8a2d\u5b9a\u4e26\u91cd\u65b0\u555f\u52d5 Home Assistant \u4ee5\u4fee\u6b63\u6b64\u554f\u984c\u3002", + "title": "APC UPS Daemon YAML \u8a2d\u5b9a\u5373\u5c07\u79fb\u9664" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/ja.json b/homeassistant/components/apple_tv/translations/ja.json index 860bf4c961b3f08467127f072dfeac368a3f707b..23032f0076fbfc8a215ceb1ac4fa3411950e3ca1 100644 --- a/homeassistant/components/apple_tv/translations/ja.json +++ b/homeassistant/components/apple_tv/translations/ja.json @@ -22,7 +22,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "`{name}` \u3068\u3044\u3046\u540d\u524d\u306eApple TV\u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u3088\u3046\u3068\u3057\u3066\u3044\u307e\u3059\u3002 \n\n **\u51e6\u7406\u3092\u5b8c\u4e86\u3059\u308b\u306b\u306f\u3001\u8907\u6570\u306ePIN\u30b3\u30fc\u30c9\u306e\u5165\u529b\u304c\u5fc5\u8981\u306b\u306a\u308b\u3053\u3068\u304c\u3042\u308a\u307e\u3059\u3002** \n\n\u3053\u306e\u7d71\u5408\u3067\u306f\u3001Apple TV\u306e\u96fb\u6e90\u3092\u30aa\u30d5\u306b\u3059\u308b\u3053\u3068\u306f *\u3067\u304d\u306a\u3044* \u3053\u3068\u306b\u6ce8\u610f\u3057\u3066\u304f\u3060\u3055\u3044\u3002 Home Assistant\u306e\u30e1\u30c7\u30a3\u30a2\u30d7\u30ec\u30fc\u30e4\u30fc\u306e\u307f\u304c\u30aa\u30d5\u306b\u306a\u308a\u307e\u3059\uff01", + "description": "`{type}` \u30bf\u30a4\u30d7\u3067 `{name}` \u3068\u3044\u3046\u540d\u524d\u306eApple TV\u3092Home Assistant\u306b\u8ffd\u52a0\u3057\u3088\u3046\u3068\u3057\u3066\u3044\u307e\u3059\u3002 \n\n **\u51e6\u7406\u3092\u5b8c\u4e86\u3059\u308b\u306b\u306f\u3001\u8907\u6570\u306ePIN\u30b3\u30fc\u30c9\u306e\u5165\u529b\u304c\u5fc5\u8981\u306b\u306a\u308b\u3053\u3068\u304c\u3042\u308a\u307e\u3059\u3002** \n\n\u3053\u306e\u7d71\u5408\u3067\u306f\u3001Apple TV\u306e\u96fb\u6e90\u3092\u30aa\u30d5\u306b\u3059\u308b\u3053\u3068\u306f *\u3067\u304d\u306a\u3044* \u3053\u3068\u306b\u6ce8\u610f\u3057\u3066\u304f\u3060\u3055\u3044\u3002 Home Assistant\u306e\u30e1\u30c7\u30a3\u30a2\u30d7\u30ec\u30fc\u30e4\u30fc\u306e\u307f\u304c\u30aa\u30d5\u306b\u306a\u308a\u307e\u3059\uff01", "title": "Apple TV\u306e\u8ffd\u52a0\u3092\u78ba\u8a8d\u3059\u308b" }, "pair_no_pin": { @@ -56,7 +56,7 @@ "data": { "device_input": "\u30c7\u30d0\u30a4\u30b9" }, - "description": "\u307e\u305a\u3001\u8ffd\u52a0\u3057\u305f\u3044Apple TV\u306e\u30c7\u30d0\u30a4\u30b9\u540d(Kitchen \u3084 Bedroom\u306a\u3069)\u304bIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u3067\u30c7\u30d0\u30a4\u30b9\u304c\u81ea\u52d5\u7684\u306b\u898b\u3064\u304b\u3063\u305f\u5834\u5408\u306f\u3001\u4ee5\u4e0b\u306b\u8868\u793a\u3055\u308c\u307e\u3059\u3002\n\n\u30c7\u30d0\u30a4\u30b9\u304c\u8868\u793a\u3055\u308c\u306a\u3044\u5834\u5408\u3084\u554f\u984c\u304c\u767a\u751f\u3057\u305f\u5834\u5408\u306f\u3001\u30c7\u30d0\u30a4\u30b9\u306eIP\u30a2\u30c9\u30ec\u30b9\u3092\u6307\u5b9a\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002\n\n{devices}", + "description": "\u307e\u305a\u3001\u8ffd\u52a0\u3057\u305f\u3044Apple TV\u306e\u30c7\u30d0\u30a4\u30b9\u540d(Kitchen \u3084 Bedroom\u306a\u3069)\u304bIP\u30a2\u30c9\u30ec\u30b9\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u3067\u30c7\u30d0\u30a4\u30b9\u304c\u81ea\u52d5\u7684\u306b\u898b\u3064\u304b\u3063\u305f\u5834\u5408\u306f\u3001\u4ee5\u4e0b\u306b\u8868\u793a\u3055\u308c\u307e\u3059\u3002\n\n\u30c7\u30d0\u30a4\u30b9\u304c\u8868\u793a\u3055\u308c\u306a\u3044\u5834\u5408\u3084\u554f\u984c\u304c\u767a\u751f\u3057\u305f\u5834\u5408\u306f\u3001\u30c7\u30d0\u30a4\u30b9\u306eIP\u30a2\u30c9\u30ec\u30b9\u3092\u6307\u5b9a\u3057\u3066\u307f\u3066\u304f\u3060\u3055\u3044\u3002", "title": "\u65b0\u3057\u3044Apple TV\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" } } diff --git a/homeassistant/components/apple_tv/translations/nb.json b/homeassistant/components/apple_tv/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/apple_tv/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/no.json b/homeassistant/components/apple_tv/translations/no.json index 97f80c7dfe79351926020a5b435923f87e78700c..d0250d31dfa2f0823d60176d6327a67f2496efb1 100644 --- a/homeassistant/components/apple_tv/translations/no.json +++ b/homeassistant/components/apple_tv/translations/no.json @@ -9,7 +9,7 @@ "inconsistent_device": "Forventede protokoller ble ikke funnet under oppdagelsen. Dette indikerer vanligvis et problem med multicast DNS (Zeroconf). Pr\u00f8v \u00e5 legge til enheten p\u00e5 nytt.", "ipv6_not_supported": "IPv6 st\u00f8ttes ikke.", "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "setup_failed": "Kunne ikke konfigurere enheten.", "unknown": "Uventet feil" }, diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index a1b2efcad89e693755643434e0b178e6849981a0..c0dc9ab44971420de4c78f631c1770e15529b98c 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==1.0.0"], + "requirements": ["apprise==1.1.0"], "codeowners": ["@caronc"], "iot_class": "cloud_push", "loggers": ["apprise"] diff --git a/homeassistant/components/aqualogic/__init__.py b/homeassistant/components/aqualogic/__init__.py index 94941b307133246db403d804dfc258f816f09afe..28e57c2b351e12b78f69b3d9993856c4b0b9c48c 100644 --- a/homeassistant/components/aqualogic/__init__.py +++ b/homeassistant/components/aqualogic/__init__.py @@ -1,4 +1,6 @@ """Support for AquaLogic devices.""" +from __future__ import annotations + from datetime import timedelta import logging import threading @@ -13,7 +15,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType @@ -50,7 +52,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class AquaLogicProcessor(threading.Thread): """AquaLogic event processor thread.""" - def __init__(self, hass, host, port): + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: """Initialize the data object.""" super().__init__(daemon=True) self._hass = hass @@ -59,27 +61,28 @@ class AquaLogicProcessor(threading.Thread): self._shutdown = False self._panel = None - def start_listen(self, event): + def start_listen(self, event: Event) -> None: """Start event-processing thread.""" _LOGGER.debug("Event processing thread started") self.start() - def shutdown(self, event): + def shutdown(self, event: Event) -> None: """Signal shutdown of processing event.""" _LOGGER.debug("Event processing signaled exit") self._shutdown = True - def data_changed(self, panel): + def data_changed(self, panel: AquaLogic) -> None: """Aqualogic data changed callback.""" dispatcher_send(self._hass, UPDATE_TOPIC) - def run(self): + def run(self) -> None: """Event thread.""" while True: - self._panel = AquaLogic() - self._panel.connect(self._host, self._port) - self._panel.process(self.data_changed) + panel = AquaLogic() + self._panel = panel + panel.connect(self._host, self._port) + panel.process(self.data_changed) if self._shutdown: return @@ -88,6 +91,6 @@ class AquaLogicProcessor(threading.Thread): time.sleep(RECONNECT_INTERVAL.total_seconds()) @property - def panel(self): + def panel(self) -> AquaLogic | None: """Retrieve the AquaLogic object.""" return self._panel diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index d575beb03677cbae438e97069f8e66359a270335..e8abc3bae6235ca8e2c1d2e1d33dd02b1c7e14ff 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -24,7 +24,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, UPDATE_TOPIC +from . import DOMAIN, UPDATE_TOPIC, AquaLogicProcessor @dataclass @@ -120,7 +120,7 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" - processor = hass.data[DOMAIN] + processor: AquaLogicProcessor = hass.data[DOMAIN] monitored_conditions = config[CONF_MONITORED_CONDITIONS] entities = [ @@ -138,7 +138,11 @@ class AquaLogicSensor(SensorEntity): entity_description: AquaLogicSensorEntityDescription _attr_should_poll = False - def __init__(self, processor, description: AquaLogicSensorEntityDescription): + def __init__( + self, + processor: AquaLogicProcessor, + description: AquaLogicSensorEntityDescription, + ) -> None: """Initialize sensor.""" self.entity_description = description self._processor = processor @@ -153,7 +157,7 @@ class AquaLogicSensor(SensorEntity): ) @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update callback.""" if (panel := self._processor.panel) is not None: if panel.is_metric: diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index e04bc8595fae0e2deb2b4ff0b1121c7da9772ed5..e693df0a0c15024fca20c7f36fc9b63d6b224536 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -14,7 +14,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, UPDATE_TOPIC +from . import DOMAIN, UPDATE_TOPIC, AquaLogicProcessor SWITCH_TYPES = { "lights": "Lights", @@ -47,7 +47,7 @@ async def async_setup_platform( """Set up the switch platform.""" switches = [] - processor = hass.data[DOMAIN] + processor: AquaLogicProcessor = hass.data[DOMAIN] for switch_type in config[CONF_MONITORED_CONDITIONS]: switches.append(AquaLogicSwitch(processor, switch_type)) @@ -59,7 +59,7 @@ class AquaLogicSwitch(SwitchEntity): _attr_should_poll = False - def __init__(self, processor, switch_type): + def __init__(self, processor: AquaLogicProcessor, switch_type: str) -> None: """Initialize switch.""" self._processor = processor self._state_name = { @@ -77,12 +77,11 @@ class AquaLogicSwitch(SwitchEntity): self._attr_name = f"AquaLogic {SWITCH_TYPES[switch_type]}" @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" if (panel := self._processor.panel) is None: return False - state = panel.get_state(self._state_name) - return state + return panel.get_state(self._state_name) # type: ignore[no-any-return] def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" diff --git a/homeassistant/components/aseko_pool_live/translations/bg.json b/homeassistant/components/aseko_pool_live/translations/bg.json index 982674c337e840477c203b59576b7269f460a388..352bc37d0dbc24d1431158a15fb73bae2c9ba392 100644 --- a/homeassistant/components/aseko_pool_live/translations/bg.json +++ b/homeassistant/components/aseko_pool_live/translations/bg.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "email": "Email", + "email": "\u0418\u043c\u0435\u0439\u043b", "password": "\u041f\u0430\u0440\u043e\u043b\u0430" } } diff --git a/homeassistant/components/aseko_pool_live/translations/nb.json b/homeassistant/components/aseko_pool_live/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index fc2d4ede26d2e2ade0bc62d954ba1451eb28b7dc..efdf4993927c05722fba9e2e9eb1fe0ddf56c4b0 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -47,8 +47,7 @@ def add_entities( new_tracked.append(AsusWrtDevice(router, device)) tracked.add(mac) - if new_tracked: - async_add_entities(new_tracked) + async_add_entities(new_tracked) class AsusWrtDevice(ScannerEntity): diff --git a/homeassistant/components/asuswrt/translations/nb.json b/homeassistant/components/asuswrt/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/asuswrt/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/bg.json b/homeassistant/components/august/translations/bg.json index 224e3324cb6eb3cd2ab8095ce048028b57362d1c..f2dccb231c100f490e7b00aab9298631aad0d595 100644 --- a/homeassistant/components/august/translations/bg.json +++ b/homeassistant/components/august/translations/bg.json @@ -8,6 +8,9 @@ "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430" } + }, + "validation": { + "title": "\u0414\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" } } } diff --git a/homeassistant/components/august/translations/nb.json b/homeassistant/components/august/translations/nb.json index 0b5511ab84542e9b06ec749d4d7aebe5c3cf8ffb..33c32bb9d3565386c00a907ce49fb5ee827473fa 100644 --- a/homeassistant/components/august/translations/nb.json +++ b/homeassistant/components/august/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user_validate": { "data": { diff --git a/homeassistant/components/august/translations/no.json b/homeassistant/components/august/translations/no.json index 8ea4cd7141f3589d009dd3fbf179bb1d4ef613ab..11e30ed8bf66a6fdd6527b9291c95f67a5f14e1e 100644 --- a/homeassistant/components/august/translations/no.json +++ b/homeassistant/components/august/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/august_ble/manifest.json b/homeassistant/components/august_ble/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..882759b12097dc2b0572de366a2835946c76fda4 --- /dev/null +++ b/homeassistant/components/august_ble/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "august_ble", + "name": "August Bluetooth", + "integration_type": "virtual", + "supported_by": "yalexs_ble" +} diff --git a/homeassistant/components/aussie_broadband/translations/nb.json b/homeassistant/components/aussie_broadband/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..cff2aa964e01f8e78cca3b5ec639bff19f440315 100644 --- a/homeassistant/components/aussie_broadband/translations/nb.json +++ b/homeassistant/components/aussie_broadband/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { @@ -7,5 +10,10 @@ } } } + }, + "options": { + "abort": { + "unknown": "Uventet feil" + } } } \ No newline at end of file diff --git a/homeassistant/components/aussie_broadband/translations/no.json b/homeassistant/components/aussie_broadband/translations/no.json index 00e911bbfbaee0f5f42e84389f02d9042b215c66..c755bcc3e9360d8690ff1c4ff2a40ba905ff3ced 100644 --- a/homeassistant/components/aussie_broadband/translations/no.json +++ b/homeassistant/components/aussie_broadband/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Kontoen er allerede konfigurert", "no_services_found": "Ingen tjenester ble funnet for denne kontoen", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 3037d7cc3a710ae63bbd4238c424fed19cba7220..234fcc978397a502ae165d1742adad835624c6b6 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -1,7 +1,9 @@ """Allow to set up simple automation rules via the config file.""" from __future__ import annotations +import asyncio from collections.abc import Callable, Mapping +from dataclasses import dataclass import logging from typing import Any, Protocol, cast @@ -51,7 +53,7 @@ from homeassistant.exceptions import ( ServiceNotFound, TemplateError, ) -from homeassistant.helpers import condition, extract_domain_configs +from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent @@ -240,11 +242,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # we will create entities before firing EVENT_COMPONENT_LOADED await async_process_integration_platform_for_component(hass, DOMAIN) - # To register the automation blueprints + # Register automation as valid domain for Blueprint async_get_blueprints(hass) - if not await _async_process_config(hass, config, component): - await async_get_blueprints(hass).async_populate() + await _async_process_config(hass, config, component) + + # Add some default blueprints to blueprints/automation, does nothing + # if blueprints/automation already exists + await async_get_blueprints(hass).async_populate() async def trigger_service_handler( entity: AutomationEntity, service_call: ServiceCall @@ -274,9 +279,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service_call: ServiceCall) -> None: """Remove all automations and load new ones from config.""" - if (conf := await component.async_prepare_reload()) is None: + await async_get_blueprints(hass).async_reset_cache() + if (conf := await component.async_prepare_reload(skip_reset=True)) is None: return - async_get_blueprints(hass).async_reset_cache() await _async_process_config(hass, conf, component) hass.bus.async_fire(EVENT_AUTOMATION_RELOADED, context=service_call.context) @@ -660,113 +665,203 @@ class AutomationEntity(ToggleEntity, RestoreEntity): ) -async def _async_process_config( +@dataclass +class AutomationEntityConfig: + """Container for prepared automation entity configuration.""" + + config_block: ConfigType + list_no: int + raw_blueprint_inputs: ConfigType | None + raw_config: ConfigType | None + + +async def _prepare_automation_config( hass: HomeAssistant, - config: dict[str, Any], - component: EntityComponent[AutomationEntity], -) -> bool: - """Process config and add automations. + config: ConfigType, +) -> list[AutomationEntityConfig]: + """Parse configuration and prepare automation entity configuration.""" + automation_configs: list[AutomationEntityConfig] = [] - Returns if blueprints were used. - """ + conf: list[ConfigType | blueprint.BlueprintInputs] = config[DOMAIN] + + for list_no, config_block in enumerate(conf): + raw_blueprint_inputs = None + raw_config = None + if isinstance(config_block, blueprint.BlueprintInputs): + blueprint_inputs = config_block + raw_blueprint_inputs = blueprint_inputs.config_with_inputs + + try: + raw_config = blueprint_inputs.async_substitute() + config_block = cast( + dict[str, Any], + await async_validate_config_item(hass, raw_config), + ) + except vol.Invalid as err: + LOGGER.error( + "Blueprint %s generated invalid automation with inputs %s: %s", + blueprint_inputs.blueprint.name, + blueprint_inputs.inputs, + humanize_error(config_block, err), + ) + continue + else: + raw_config = cast(AutomationConfig, config_block).raw_config + + automation_configs.append( + AutomationEntityConfig( + config_block, list_no, raw_blueprint_inputs, raw_config + ) + ) + + return automation_configs + + +def _automation_name(automation_config: AutomationEntityConfig) -> str: + """Return the configured name of an automation.""" + config_block = automation_config.config_block + list_no = automation_config.list_no + return config_block.get(CONF_ALIAS) or f"{DOMAIN} {list_no}" + + +async def _create_automation_entities( + hass: HomeAssistant, automation_configs: list[AutomationEntityConfig] +) -> list[AutomationEntity]: + """Create automation entities from prepared configuration.""" entities: list[AutomationEntity] = [] - blueprints_used = False - for config_key in extract_domain_configs(config, DOMAIN): - conf: list[dict[str, Any] | blueprint.BlueprintInputs] = config[config_key] + for automation_config in automation_configs: + config_block = automation_config.config_block - for list_no, config_block in enumerate(conf): - raw_blueprint_inputs = None - raw_config = None - if isinstance(config_block, blueprint.BlueprintInputs): - blueprints_used = True - blueprint_inputs = config_block - raw_blueprint_inputs = blueprint_inputs.config_with_inputs + automation_id: str | None = config_block.get(CONF_ID) + name = _automation_name(automation_config) - try: - raw_config = blueprint_inputs.async_substitute() - config_block = cast( - dict[str, Any], - await async_validate_config_item(hass, raw_config), - ) - except vol.Invalid as err: - LOGGER.error( - "Blueprint %s generated invalid automation with inputs %s: %s", - blueprint_inputs.blueprint.name, - blueprint_inputs.inputs, - humanize_error(config_block, err), - ) - continue - else: - raw_config = cast(AutomationConfig, config_block).raw_config + initial_state: bool | None = config_block.get(CONF_INITIAL_STATE) - automation_id: str | None = config_block.get(CONF_ID) - name: str = config_block.get(CONF_ALIAS) or f"{config_key} {list_no}" + action_script = Script( + hass, + config_block[CONF_ACTION], + name, + DOMAIN, + running_description="automation actions", + script_mode=config_block[CONF_MODE], + max_runs=config_block[CONF_MAX], + max_exceeded=config_block[CONF_MAX_EXCEEDED], + logger=LOGGER, + # We don't pass variables here + # Automation will already render them to use them in the condition + # and so will pass them on to the script. + ) - initial_state: bool | None = config_block.get(CONF_INITIAL_STATE) + if CONF_CONDITION in config_block: + cond_func = await _async_process_if(hass, name, config_block) - action_script = Script( - hass, - config_block[CONF_ACTION], - name, - DOMAIN, - running_description="automation actions", - script_mode=config_block[CONF_MODE], - max_runs=config_block[CONF_MAX], - max_exceeded=config_block[CONF_MAX_EXCEEDED], - logger=LOGGER, - # We don't pass variables here - # Automation will already render them to use them in the condition - # and so will pass them on to the script. + if cond_func is None: + continue + else: + cond_func = None + + # Add trigger variables to variables + variables = None + if CONF_TRIGGER_VARIABLES in config_block: + variables = ScriptVariables( + dict(config_block[CONF_TRIGGER_VARIABLES].as_dict()) ) + if CONF_VARIABLES in config_block: + if variables: + variables.variables.update(config_block[CONF_VARIABLES].as_dict()) + else: + variables = config_block[CONF_VARIABLES] + + entity = AutomationEntity( + automation_id, + name, + config_block[CONF_TRIGGER], + cond_func, + action_script, + initial_state, + variables, + config_block.get(CONF_TRIGGER_VARIABLES), + automation_config.raw_config, + automation_config.raw_blueprint_inputs, + config_block[CONF_TRACE], + ) + entities.append(entity) - if CONF_CONDITION in config_block: - cond_func = await _async_process_if(hass, name, config, config_block) + return entities + + +async def _async_process_config( + hass: HomeAssistant, + config: dict[str, Any], + component: EntityComponent[AutomationEntity], +) -> None: + """Process config and add automations.""" - if cond_func is None: + def automation_matches_config( + automation: AutomationEntity, config: AutomationEntityConfig + ) -> bool: + name = _automation_name(config) + return automation.name == name and automation.raw_config == config.raw_config + + def find_matches( + automations: list[AutomationEntity], + automation_configs: list[AutomationEntityConfig], + ) -> tuple[set[int], set[int]]: + """Find matches between a list of automation entities and a list of configurations. + + An automation or configuration is only allowed to match at most once to handle + the case of multiple automations with identical configuration. + + Returns a tuple of sets of indices: ({automation_matches}, {config_matches}) + """ + automation_matches: set[int] = set() + config_matches: set[int] = set() + + for automation_idx, automation in enumerate(automations): + for config_idx, config in enumerate(automation_configs): + if config_idx in config_matches: + # Only allow an automation config to match at most once continue - else: - cond_func = None + if automation_matches_config(automation, config): + automation_matches.add(automation_idx) + config_matches.add(config_idx) + # Only allow an automation to match at most once + break - # Add trigger variables to variables - variables = None - if CONF_TRIGGER_VARIABLES in config_block: - variables = ScriptVariables( - dict(config_block[CONF_TRIGGER_VARIABLES].as_dict()) - ) - if CONF_VARIABLES in config_block: - if variables: - variables.variables.update(config_block[CONF_VARIABLES].as_dict()) - else: - variables = config_block[CONF_VARIABLES] - - entity = AutomationEntity( - automation_id, - name, - config_block[CONF_TRIGGER], - cond_func, - action_script, - initial_state, - variables, - config_block.get(CONF_TRIGGER_VARIABLES), - raw_config, - raw_blueprint_inputs, - config_block[CONF_TRACE], - ) + return automation_matches, config_matches + + automation_configs = await _prepare_automation_config(hass, config) + automations: list[AutomationEntity] = list(component.entities) - entities.append(entity) + # Find automations and configurations which have matches + automation_matches, config_matches = find_matches(automations, automation_configs) - if entities: - await component.async_add_entities(entities) + # Remove automations which have changed config or no longer exist + tasks = [ + automation.async_remove() + for idx, automation in enumerate(automations) + if idx not in automation_matches + ] + await asyncio.gather(*tasks) + + # Create automations which have changed config or have been added + updated_automation_configs = [ + config + for idx, config in enumerate(automation_configs) + if idx not in config_matches + ] + entities = await _create_automation_entities(hass, updated_automation_configs) + await component.async_add_entities(entities) - return blueprints_used + return async def _async_process_if( - hass: HomeAssistant, name: str, config: dict[str, Any], p_config: dict[str, Any] + hass: HomeAssistant, name: str, config: dict[str, Any] ) -> IfAction | None: """Process if checks.""" - if_configs = p_config[CONF_CONDITION] + if_configs = config[CONF_CONDITION] checks: list[condition.ConditionCheckerType] = [] for if_config in if_configs: diff --git a/homeassistant/components/automation/translations/nl.json b/homeassistant/components/automation/translations/nl.json index 7ef3acc9f2c99e2024b036f98e214e0cd55fd6c6..4e36f46b98012ad46d04bf72cffa4df89d10db61 100644 --- a/homeassistant/components/automation/translations/nl.json +++ b/homeassistant/components/automation/translations/nl.json @@ -1,4 +1,9 @@ { + "issues": { + "service_not_found": { + "title": "{name} gebruikt een onbekende service" + } + }, "state": { "_": { "off": "Uit", diff --git a/homeassistant/components/awair/translations/he.json b/homeassistant/components/awair/translations/he.json index 2494d0bbd28b0e87bd9927f9b983d3c7801e4c22..56e562de0c5a3a65b34cb97232eacb85a5bcd134 100644 --- a/homeassistant/components/awair/translations/he.json +++ b/homeassistant/components/awair/translations/he.json @@ -10,6 +10,12 @@ "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { + "local_pick": { + "data": { + "device": "\u05d4\u05ea\u05e7\u05df", + "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP" + } + }, "reauth": { "data": { "access_token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4", diff --git a/homeassistant/components/awair/translations/nb.json b/homeassistant/components/awair/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/awair/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/awair/translations/no.json b/homeassistant/components/awair/translations/no.json index b71736d7a6d125335cc521ed292c5fe4e9d361a4..983a47ecfed1dd907af678039190ce8c8fa17be1 100644 --- a/homeassistant/components/awair/translations/no.json +++ b/homeassistant/components/awair/translations/no.json @@ -5,7 +5,7 @@ "already_configured_account": "Kontoen er allerede konfigurert", "already_configured_device": "Enheten er allerede konfigurert", "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "unreachable": "Tilkobling mislyktes" }, "error": { diff --git a/homeassistant/components/awair/translations/pl.json b/homeassistant/components/awair/translations/pl.json index 7bd01486f8810bef2bd409248c23547df57376a7..c42f9863b1c449740469a8f2dad679d1c5e692c5 100644 --- a/homeassistant/components/awair/translations/pl.json +++ b/homeassistant/components/awair/translations/pl.json @@ -29,7 +29,7 @@ "data": { "host": "Adres IP" }, - "description": "Lokalny interfejs API Awair musi by\u0107 w\u0142\u0105czony, wykonuj\u0105c nast\u0119puj\u0105ce czynno\u015bci: {url}" + "description": "Post\u0119puj zgodnie z [tymi instrukcjami]({url}), aby dowiedzie\u0107 si\u0119, jak w\u0142\u0105czy\u0107 lokalny interfejs API Awair. \n\n Po zako\u0144czeniu kliknij Zatwierd\u017a." }, "local_pick": { "data": { diff --git a/homeassistant/components/aws/__init__.py b/homeassistant/components/aws/__init__.py index 156684f084c96028a9cfabab71e3a77c1ecec860..0638ae94de2464779d0d089c720a92ab06d0ae32 100644 --- a/homeassistant/components/aws/__init__.py +++ b/homeassistant/components/aws/__init__.py @@ -51,7 +51,7 @@ DEFAULT_CREDENTIAL = [ {CONF_NAME: "default", CONF_PROFILE_NAME: "default", CONF_VALIDATE: False} ] -SUPPORTED_SERVICES = ["lambda", "sns", "sqs"] +SUPPORTED_SERVICES = ["lambda", "sns", "sqs", "events"] NOTIFY_PLATFORM_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/aws/notify.py b/homeassistant/components/aws/notify.py index c4e450a4aab3e9de3b305337d0c01f11078b2793..e11b9db6c5edd4105c4b7798a1a54715f0e2bfe0 100644 --- a/homeassistant/components/aws/notify.py +++ b/homeassistant/components/aws/notify.py @@ -3,6 +3,7 @@ import asyncio import base64 import json import logging +from typing import Any from aiobotocore.session import AioSession @@ -105,6 +106,9 @@ async def async_get_service(hass, config, discovery_info=None): if service == "sqs": return AWSSQS(session, aws_config) + if service == "events": + return AWSEventBridge(session, aws_config) + # should not reach here since service was checked in schema return None @@ -128,7 +132,7 @@ class AWSLambda(AWSNotify): super().__init__(session, aws_config) self.context = context - async def async_send_message(self, message="", **kwargs): + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send notification to specified LAMBDA ARN.""" if not kwargs.get(ATTR_TARGET): _LOGGER.error("At least one target is required") @@ -161,7 +165,7 @@ class AWSSNS(AWSNotify): service = "sns" - async def async_send_message(self, message="", **kwargs): + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send notification to specified SNS ARN.""" if not kwargs.get(ATTR_TARGET): _LOGGER.error("At least one target is required") @@ -199,7 +203,7 @@ class AWSSQS(AWSNotify): service = "sqs" - async def async_send_message(self, message="", **kwargs): + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send notification to specified SQS ARN.""" if not kwargs.get(ATTR_TARGET): _LOGGER.error("At least one target is required") @@ -231,3 +235,52 @@ class AWSSQS(AWSNotify): if tasks: await asyncio.gather(*tasks) + + +class AWSEventBridge(AWSNotify): + """Implement the notification service for the AWS EventBridge service.""" + + service = "events" + + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: + """Send notification to specified EventBus.""" + + cleaned_kwargs = {k: v for k, v in kwargs.items() if v is not None} + data = cleaned_kwargs.get(ATTR_DATA, {}) + detail = ( + json.dumps(data["detail"]) + if "detail" in data + else json.dumps({"message": message}) + ) + + async with self.session.create_client( + self.service, **self.aws_config + ) as client: + tasks = [] + entries = [] + for target in kwargs.get(ATTR_TARGET, [None]): + entry = { + "Source": data.get("source", "homeassistant"), + "Resources": data.get("resources", []), + "Detail": detail, + "DetailType": data.get("detail_type", ""), + } + if target: + entry["EventBusName"] = target + + entries.append(entry) + for i in range(0, len(entries), 10): + tasks.append( + client.put_events(Entries=entries[i : min(i + 10, len(entries))]) + ) + + if tasks: + results = await asyncio.gather(*tasks) + for result in results: + for entry in result["Entries"]: + if len(entry.get("EventId", "")) == 0: + _LOGGER.error( + "Failed to send event: ErrorCode=%s ErrorMessage=%s", + entry["ErrorCode"], + entry["ErrorMessage"], + ) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index c69d7346b9ea38d332c79fb5b17c9994b310e27e..aad057cd4b3b3bc1722407f674481b1d6487e5ee 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -5,7 +5,9 @@ "documentation": "https://www.home-assistant.io/integrations/axis", "requirements": ["axis==44"], "dhcp": [ - { "registered_devices": true }, + { + "registered_devices": true + }, { "hostname": "axis-00408c*", "macaddress": "00408C*" @@ -27,20 +29,27 @@ "zeroconf": [ { "type": "_axis-video._tcp.local.", - "properties": { "macaddress": "00408c*" } + "properties": { + "macaddress": "00408c*" + } }, { "type": "_axis-video._tcp.local.", - "properties": { "macaddress": "accc8e*" } + "properties": { + "macaddress": "accc8e*" + } }, { "type": "_axis-video._tcp.local.", - "properties": { "macaddress": "b8a44f*" } + "properties": { + "macaddress": "b8a44f*" + } } ], "after_dependencies": ["mqtt"], "codeowners": ["@Kane610"], "quality_scale": "platinum", "iot_class": "local_push", + "integration_type": "device", "loggers": ["axis"] } diff --git a/homeassistant/components/azure_devops/translations/no.json b/homeassistant/components/azure_devops/translations/no.json index ba4ff946595c22aeaf47ab1f9edbb8f159784857..e765e4b79ceceaf39dbc2215b34863aab1413b15 100644 --- a/homeassistant/components/azure_devops/translations/no.json +++ b/homeassistant/components/azure_devops/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/azure_event_hub/client.py b/homeassistant/components/azure_event_hub/client.py index 27a4eabf535c73854c204bbb6ddee33dca9d184b..90880a92b64fc72a8735e1c013004e2a2b6cb3e3 100644 --- a/homeassistant/components/azure_event_hub/client.py +++ b/homeassistant/components/azure_event_hub/client.py @@ -1,6 +1,7 @@ """File for Azure Event Hub models.""" from __future__ import annotations +from abc import ABC, abstractmethod from dataclasses import dataclass import logging @@ -12,12 +13,13 @@ _LOGGER = logging.getLogger(__name__) @dataclass -class AzureEventHubClient: +class AzureEventHubClient(ABC): """Class for the Azure Event Hub client. Use from_input to initialize.""" event_hub_instance_name: str @property + @abstractmethod def client(self) -> EventHubProducerClient: """Return the client.""" diff --git a/homeassistant/components/azure_event_hub/translations/nb.json b/homeassistant/components/azure_event_hub/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/azure_event_hub/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 5c12a7649412dcf2ab3cc0911d2f3f1f5c9788e5..c203019cca911d6259a96738db90ea86db7d6f9f 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -1,4 +1,6 @@ """Websocket commands for the Backup integration.""" +from typing import Any + import voluptuous as vol from homeassistant.components import websocket_api @@ -22,7 +24,7 @@ def async_register_websocket_handlers(hass: HomeAssistant) -> None: async def handle_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """List all stored backups.""" manager: BackupManager = hass.data[DOMAIN] @@ -47,7 +49,7 @@ async def handle_info( async def handle_remove( hass: HomeAssistant, connection: websocket_api.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Remove a backup.""" manager: BackupManager = hass.data[DOMAIN] @@ -61,7 +63,7 @@ async def handle_remove( async def handle_create( hass: HomeAssistant, connection: websocket_api.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Generate a backup.""" manager: BackupManager = hass.data[DOMAIN] diff --git a/homeassistant/components/baf/sensor.py b/homeassistant/components/baf/sensor.py index 7b93b22fe2ffc2cb124376a13e1b0e7952ec7ea3..79ae320969be033b59e6375e60b353c725d91b5c 100644 --- a/homeassistant/components/baf/sensor.py +++ b/homeassistant/components/baf/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, TEMP_CELSIUS +from homeassistant.const import PERCENTAGE, REVOLUTIONS_PER_MINUTE, TEMP_CELSIUS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -65,7 +65,7 @@ FAN_SENSORS = ( BAFSensorDescription( key="current_rpm", name="Current RPM", - native_unit_of_measurement="RPM", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: cast(Optional[int], device.current_rpm), @@ -73,7 +73,7 @@ FAN_SENSORS = ( BAFSensorDescription( key="target_rpm", name="Target RPM", - native_unit_of_measurement="RPM", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: cast(Optional[int], device.target_rpm), diff --git a/homeassistant/components/baf/translations/nb.json b/homeassistant/components/baf/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/baf/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/balboa/translations/nb.json b/homeassistant/components/balboa/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/balboa/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 706c7ecdfd78e925d91c498ce2df8af176067c12..28af050e85ee9ed548ffc615e563e5f2e5edcd6f 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -2,12 +2,18 @@ from __future__ import annotations from collections import OrderedDict +from collections.abc import Callable import logging from typing import Any +from uuid import UUID import voluptuous as vol -from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA, + BinarySensorDeviceClass, + BinarySensorEntity, +) from homeassistant.const import ( CONF_ABOVE, CONF_BELOW, @@ -16,43 +22,46 @@ from homeassistant.const import ( CONF_NAME, CONF_PLATFORM, CONF_STATE, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConditionError, TemplateError from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( TrackTemplate, + TrackTemplateResult, + TrackTemplateResultInfo, async_track_state_change_event, async_track_template_result, ) from homeassistant.helpers.reload import async_setup_reload_service -from homeassistant.helpers.template import result_as_boolean +from homeassistant.helpers.template import Template, result_as_boolean from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN, PLATFORMS +from .const import ( + ATTR_OBSERVATIONS, + ATTR_OCCURRED_OBSERVATION_ENTITIES, + ATTR_PROBABILITY, + ATTR_PROBABILITY_THRESHOLD, + CONF_OBSERVATIONS, + CONF_P_GIVEN_F, + CONF_P_GIVEN_T, + CONF_PRIOR, + CONF_PROBABILITY_THRESHOLD, + CONF_TEMPLATE, + CONF_TO_STATE, + DEFAULT_NAME, + DEFAULT_PROBABILITY_THRESHOLD, +) +from .helpers import Observation from .repairs import raise_mirrored_entries, raise_no_prob_given_false -ATTR_OBSERVATIONS = "observations" -ATTR_OCCURRED_OBSERVATION_ENTITIES = "occurred_observation_entities" -ATTR_PROBABILITY = "probability" -ATTR_PROBABILITY_THRESHOLD = "probability_threshold" - -CONF_OBSERVATIONS = "observations" -CONF_PRIOR = "prior" -CONF_TEMPLATE = "template" -CONF_PROBABILITY_THRESHOLD = "probability_threshold" -CONF_P_GIVEN_F = "prob_given_false" -CONF_P_GIVEN_T = "prob_given_true" -CONF_TO_STATE = "to_state" - -DEFAULT_NAME = "Bayesian Binary Sensor" -DEFAULT_PROBABILITY_THRESHOLD = 0.5 - _LOGGER = logging.getLogger(__name__) @@ -92,6 +101,7 @@ TEMPLATE_SCHEMA = vol.Schema( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE_CLASS): cv.string, vol.Required(CONF_OBSERVATIONS): vol.Schema( vol.All( @@ -107,7 +117,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def update_probability(prior, prob_given_true, prob_given_false): +def update_probability( + prior: float, prob_given_true: float, prob_given_false: float +) -> float: """Update probability using Bayes' rule.""" numerator = prob_given_true * prior denominator = numerator + prob_given_false * (1 - prior) @@ -123,18 +135,19 @@ async def async_setup_platform( """Set up the Bayesian Binary sensor.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - name = config[CONF_NAME] - observations = config[CONF_OBSERVATIONS] - prior = config[CONF_PRIOR] - probability_threshold = config[CONF_PROBABILITY_THRESHOLD] - device_class = config.get(CONF_DEVICE_CLASS) + name: str = config[CONF_NAME] + unique_id: str | None = config.get(CONF_UNIQUE_ID) + observations: list[ConfigType] = config[CONF_OBSERVATIONS] + prior: float = config[CONF_PRIOR] + probability_threshold: float = config[CONF_PROBABILITY_THRESHOLD] + device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) # Should deprecate in some future version (2022.10 at time of writing) & make prob_given_false required in schemas. broken_observations: list[dict[str, Any]] = [] for observation in observations: if CONF_P_GIVEN_F not in observation: text: str = f"{name}/{observation.get(CONF_ENTITY_ID,'')}{observation.get(CONF_VALUE_TEMPLATE,'')}" - raise_no_prob_given_false(hass, observation, text) + raise_no_prob_given_false(hass, text) _LOGGER.error("Missing prob_given_false YAML entry for %s", text) broken_observations.append(observation) observations = [x for x in observations if x not in broken_observations] @@ -142,7 +155,12 @@ async def async_setup_platform( async_add_entities( [ BayesianBinarySensor( - name, prior, observations, probability_threshold, device_class + name, + unique_id, + prior, + observations, + probability_threshold, + device_class, ) ] ) @@ -153,24 +171,46 @@ class BayesianBinarySensor(BinarySensorEntity): _attr_should_poll = False - def __init__(self, name, prior, observations, probability_threshold, device_class): + def __init__( + self, + name: str, + unique_id: str | None, + prior: float, + observations: list[ConfigType], + probability_threshold: float, + device_class: BinarySensorDeviceClass | None, + ) -> None: """Initialize the Bayesian sensor.""" self._attr_name = name - self._observations = observations + self._attr_unique_id = unique_id and f"bayesian-{unique_id}" + self._observations = [ + Observation( + entity_id=observation.get(CONF_ENTITY_ID), + platform=observation[CONF_PLATFORM], + prob_given_false=observation[CONF_P_GIVEN_F], + prob_given_true=observation[CONF_P_GIVEN_T], + observed=None, + to_state=observation.get(CONF_TO_STATE), + above=observation.get(CONF_ABOVE), + below=observation.get(CONF_BELOW), + value_template=observation.get(CONF_VALUE_TEMPLATE), + ) + for observation in observations + ] self._probability_threshold = probability_threshold self._attr_device_class = device_class self._attr_is_on = False - self._callbacks = [] + self._callbacks: list[TrackTemplateResultInfo] = [] self.prior = prior self.probability = prior - self.current_observations = OrderedDict({}) + self.current_observations: OrderedDict[UUID, Observation] = OrderedDict({}) self.observations_by_entity = self._build_observations_by_entity() self.observations_by_template = self._build_observations_by_template() - self.observation_handlers = { + self.observation_handlers: dict[str, Callable[[Observation], bool | None]] = { "numeric_state": self._process_numeric_state, "state": self._process_state, "multi_state": self._process_multi_state, @@ -192,7 +232,7 @@ class BayesianBinarySensor(BinarySensorEntity): """ @callback - def async_threshold_sensor_state_listener(event): + def async_threshold_sensor_state_listener(event: Event) -> None: """ Handle sensor state changes. @@ -200,7 +240,7 @@ class BayesianBinarySensor(BinarySensorEntity): then calculate the new probability. """ - entity = event.data.get("entity_id") + entity: str = event.data[CONF_ENTITY_ID] self.current_observations.update(self._record_entity_observations(entity)) self.async_set_context(event.context) @@ -215,11 +255,15 @@ class BayesianBinarySensor(BinarySensorEntity): ) @callback - def _async_template_result_changed(event, updates): + def _async_template_result_changed( + event: Event | None, updates: list[TrackTemplateResult] + ) -> None: track_template_result = updates.pop() template = track_template_result.template result = track_template_result.result - entity = event and event.data.get("entity_id") + entity: str | None = ( + None if event is None else event.data.get(CONF_ENTITY_ID) + ) if isinstance(result, TemplateError): _LOGGER.error( "TemplateError('%s') " @@ -230,13 +274,18 @@ class BayesianBinarySensor(BinarySensorEntity): self.entity_id, ) - observation = None + observed = None else: - observation = result_as_boolean(result) + observed = result_as_boolean(result) + + for observation in self.observations_by_template[template]: + observation.observed = observed - for obs in self.observations_by_template[template]: - obs_entry = {"entity_id": entity, "observation": observation, **obs} - self.current_observations[obs["id"]] = obs_entry + # in some cases a template may update because of the absence of an entity + if entity is not None: + observation.entity_id = entity + + self.current_observations[observation.id] = observation if event: self.async_set_context(event.context) @@ -255,7 +304,7 @@ class BayesianBinarySensor(BinarySensorEntity): self.current_observations.update(self._initialize_current_observations()) self.probability = self._calculate_new_probability() - self._attr_is_on = bool(self.probability >= self._probability_threshold) + self._attr_is_on = self.probability >= self._probability_threshold # detect mirrored entries for entity, observations in self.observations_by_entity.items(): @@ -263,83 +312,80 @@ class BayesianBinarySensor(BinarySensorEntity): self.hass, observations, text=f"{self._attr_name}/{entity}" ) - all_template_observations = [] - for value in self.observations_by_template.values(): - all_template_observations.append(value[0]) + all_template_observations: list[Observation] = [] + for observations in self.observations_by_template.values(): + all_template_observations.append(observations[0]) if len(all_template_observations) == 2: raise_mirrored_entries( self.hass, all_template_observations, - text=f"{self._attr_name}/{all_template_observations[0]['value_template']}", + text=f"{self._attr_name}/{all_template_observations[0].value_template}", ) @callback - def _recalculate_and_write_state(self): + def _recalculate_and_write_state(self) -> None: self.probability = self._calculate_new_probability() self._attr_is_on = bool(self.probability >= self._probability_threshold) self.async_write_ha_state() - def _initialize_current_observations(self): - local_observations = OrderedDict({}) - + def _initialize_current_observations(self) -> OrderedDict[UUID, Observation]: + local_observations: OrderedDict[UUID, Observation] = OrderedDict({}) for entity in self.observations_by_entity: local_observations.update(self._record_entity_observations(entity)) return local_observations - def _record_entity_observations(self, entity): - local_observations = OrderedDict({}) + def _record_entity_observations( + self, entity: str + ) -> OrderedDict[UUID, Observation]: + local_observations: OrderedDict[UUID, Observation] = OrderedDict({}) - for entity_obs in self.observations_by_entity[entity]: - platform = entity_obs["platform"] + for observation in self.observations_by_entity[entity]: + platform = observation.platform - observation = self.observation_handlers[platform](entity_obs) + observation.observed = self.observation_handlers[platform](observation) - obs_entry = { - "entity_id": entity, - "observation": observation, - **entity_obs, - } - local_observations[entity_obs["id"]] = obs_entry + local_observations[observation.id] = observation return local_observations - def _calculate_new_probability(self): + def _calculate_new_probability(self) -> float: prior = self.prior - for obs in self.current_observations.values(): - if obs is not None: - if obs["observation"] is True: - prior = update_probability( - prior, - obs["prob_given_true"], - obs["prob_given_false"], - ) - elif obs["observation"] is False: - prior = update_probability( - prior, - 1 - obs["prob_given_true"], - 1 - obs["prob_given_false"], - ) - elif obs["observation"] is None: - if obs["entity_id"] is not None: - _LOGGER.debug( - "Observation for entity '%s' returned None, it will not be used for Bayesian updating", - obs["entity_id"], - ) - else: - _LOGGER.debug( - "Observation for template entity returned None rather than a valid boolean, it will not be used for Bayesian updating", - ) - + for observation in self.current_observations.values(): + if observation.observed is True: + prior = update_probability( + prior, + observation.prob_given_true, + observation.prob_given_false, + ) + continue + if observation.observed is False: + prior = update_probability( + prior, + 1 - observation.prob_given_true, + 1 - observation.prob_given_false, + ) + continue + # observation.observed is None + if observation.entity_id is not None: + _LOGGER.debug( + "Observation for entity '%s' returned None, it will not be used for Bayesian updating", + observation.entity_id, + ) + continue + _LOGGER.debug( + "Observation for template entity returned None rather than a valid boolean, it will not be used for Bayesian updating", + ) + # the prior has been updated and is now the posterior return prior - def _build_observations_by_entity(self): + def _build_observations_by_entity(self) -> dict[str, list[Observation]]: """ Build and return data structure of the form below. { - "sensor.sensor1": [{"id": 0, ...}, {"id": 1, ...}], - "sensor.sensor2": [{"id": 2, ...}], + "sensor.sensor1": [Observation, Observation], + "sensor.sensor2": [Observation], ... } @@ -347,31 +393,30 @@ class BayesianBinarySensor(BinarySensorEntity): for all relevant observations to be looked up via their `entity_id`. """ - observations_by_entity: dict[str, list[OrderedDict]] = {} - for i, obs in enumerate(self._observations): - obs["id"] = i + observations_by_entity: dict[str, list[Observation]] = {} + for observation in self._observations: - if "entity_id" not in obs: + if (key := observation.entity_id) is None: continue - observations_by_entity.setdefault(obs["entity_id"], []).append(obs) + observations_by_entity.setdefault(key, []).append(observation) - for li_of_dicts in observations_by_entity.values(): - if len(li_of_dicts) == 1: + for entity_observations in observations_by_entity.values(): + if len(entity_observations) == 1: continue - for ord_dict in li_of_dicts: - if ord_dict["platform"] != "state": + for observation in entity_observations: + if observation.platform != "state": continue - ord_dict["platform"] = "multi_state" + observation.platform = "multi_state" return observations_by_entity - def _build_observations_by_template(self): + def _build_observations_by_template(self) -> dict[Template, list[Observation]]: """ Build and return data structure of the form below. { - "template": [{"id": 0, ...}, {"id": 1, ...}], - "template2": [{"id": 2, ...}], + "template": [Observation, Observation], + "template2": [Observation], ... } @@ -379,21 +424,19 @@ class BayesianBinarySensor(BinarySensorEntity): for all relevant observations to be looked up via their `template`. """ - observations_by_template = {} - for ind, obs in enumerate(self._observations): - obs["id"] = ind - - if "value_template" not in obs: + observations_by_template: dict[Template, list[Observation]] = {} + for observation in self._observations: + if observation.value_template is None: continue - template = obs.get(CONF_VALUE_TEMPLATE) - observations_by_template.setdefault(template, []).append(obs) + template = observation.value_template + observations_by_template.setdefault(template, []).append(observation) return observations_by_template - def _process_numeric_state(self, entity_observation): + def _process_numeric_state(self, entity_observation: Observation) -> bool | None: """Return True if numeric condition is met, return False if not, return None otherwise.""" - entity = entity_observation["entity_id"] + entity = entity_observation.entity_id try: if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]): @@ -401,61 +444,67 @@ class BayesianBinarySensor(BinarySensorEntity): return condition.async_numeric_state( self.hass, entity, - entity_observation.get("below"), - entity_observation.get("above"), + entity_observation.below, + entity_observation.above, None, - entity_observation, + entity_observation.to_dict(), ) except ConditionError: return None - def _process_state(self, entity_observation): - """Return True if state conditions are met.""" - entity = entity_observation["entity_id"] + def _process_state(self, entity_observation: Observation) -> bool | None: + """Return True if state conditions are met, return False if they are not. + + Returns None if the state is unavailable. + """ + + entity = entity_observation.entity_id try: if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]): return None - return condition.state( - self.hass, entity, entity_observation.get("to_state") - ) + return condition.state(self.hass, entity, entity_observation.to_state) except ConditionError: return None - def _process_multi_state(self, entity_observation): - """Return True if state conditions are met.""" - entity = entity_observation["entity_id"] + def _process_multi_state(self, entity_observation: Observation) -> bool | None: + """Return True if state conditions are met, otherwise return None. + + Never return False as all other states should have their own probabilities configured. + """ + + entity = entity_observation.entity_id try: - if condition.state(self.hass, entity, entity_observation.get("to_state")): + if condition.state(self.hass, entity, entity_observation.to_state): return True except ConditionError: return None + return None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" - attr_observations_list = [ - obs.copy() for obs in self.current_observations.values() if obs is not None - ] - - for item in attr_observations_list: - item.pop("value_template", None) return { - ATTR_OBSERVATIONS: attr_observations_list, + ATTR_PROBABILITY: round(self.probability, 2), + ATTR_PROBABILITY_THRESHOLD: self._probability_threshold, + # An entity can be in more than one observation so set then list to deduplicate ATTR_OCCURRED_OBSERVATION_ENTITIES: list( { - obs.get("entity_id") - for obs in self.current_observations.values() - if obs is not None - and obs.get("entity_id") is not None - and obs.get("observation") is not None + observation.entity_id + for observation in self.current_observations.values() + if observation is not None + and observation.entity_id is not None + and observation.observed is not None } ), - ATTR_PROBABILITY: round(self.probability, 2), - ATTR_PROBABILITY_THRESHOLD: self._probability_threshold, + ATTR_OBSERVATIONS: [ + observation.to_dict() + for observation in self.current_observations.values() + if observation is not None + ], } async def async_update(self) -> None: diff --git a/homeassistant/components/bayesian/const.py b/homeassistant/components/bayesian/const.py new file mode 100644 index 0000000000000000000000000000000000000000..5d3f978cedc593d2e7089dacc67f0cddbcb14d3b --- /dev/null +++ b/homeassistant/components/bayesian/const.py @@ -0,0 +1,17 @@ +"""Consts for using in modules.""" + +ATTR_OBSERVATIONS = "observations" +ATTR_OCCURRED_OBSERVATION_ENTITIES = "occurred_observation_entities" +ATTR_PROBABILITY = "probability" +ATTR_PROBABILITY_THRESHOLD = "probability_threshold" + +CONF_OBSERVATIONS = "observations" +CONF_PRIOR = "prior" +CONF_TEMPLATE = "template" +CONF_PROBABILITY_THRESHOLD = "probability_threshold" +CONF_P_GIVEN_F = "prob_given_false" +CONF_P_GIVEN_T = "prob_given_true" +CONF_TO_STATE = "to_state" + +DEFAULT_NAME = "Bayesian Binary Sensor" +DEFAULT_PROBABILITY_THRESHOLD = 0.5 diff --git a/homeassistant/components/bayesian/helpers.py b/homeassistant/components/bayesian/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..6e78de63607d0e84ac386d6ea41115536bbfc2af --- /dev/null +++ b/homeassistant/components/bayesian/helpers.py @@ -0,0 +1,72 @@ +"""Helpers to deal with bayesian observations.""" +from __future__ import annotations + +from dataclasses import dataclass, field +import uuid + +from homeassistant.const import ( + CONF_ABOVE, + CONF_BELOW, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_VALUE_TEMPLATE, +) +from homeassistant.helpers.template import Template + +from .const import CONF_P_GIVEN_F, CONF_P_GIVEN_T, CONF_TO_STATE + + +@dataclass +class Observation: + """Representation of a sensor or template observation. + + Either entity_id or value_template should be non-None. + """ + + entity_id: str | None + platform: str + prob_given_true: float + prob_given_false: float + to_state: str | None + above: float | None + below: float | None + value_template: Template | None + observed: bool | None = None + id: uuid.UUID = field(default_factory=uuid.uuid4) + + def to_dict(self) -> dict[str, str | float | bool | None]: + """Represent Class as a Dict for easier serialization.""" + + # Needed because dataclasses asdict() can't serialize Templates and ignores Properties. + dic = { + CONF_PLATFORM: self.platform, + CONF_ENTITY_ID: self.entity_id, + CONF_VALUE_TEMPLATE: self.template, + CONF_TO_STATE: self.to_state, + CONF_ABOVE: self.above, + CONF_BELOW: self.below, + CONF_P_GIVEN_T: self.prob_given_true, + CONF_P_GIVEN_F: self.prob_given_false, + "observed": self.observed, + } + + for key, value in dic.copy().items(): + if value is None: + del dic[key] + + return dic + + def is_mirror(self, other: Observation) -> bool: + """Dectects whether given observation is a mirror of this one.""" + return ( + self.platform == other.platform + and round(self.prob_given_true + other.prob_given_true, 1) == 1 + and round(self.prob_given_false + other.prob_given_false, 1) == 1 + ) + + @property + def template(self) -> str | None: + """Not all observations have templates and we want to get template strings.""" + if self.value_template is not None: + return self.value_template.template + return None diff --git a/homeassistant/components/bayesian/repairs.py b/homeassistant/components/bayesian/repairs.py index a1d4f142527398020b77a3c109ee492eeb73db4b..9a527636948c1cccb4fa360832fda35f3e3961e3 100644 --- a/homeassistant/components/bayesian/repairs.py +++ b/homeassistant/components/bayesian/repairs.py @@ -5,26 +5,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry from . import DOMAIN +from .helpers import Observation -def raise_mirrored_entries(hass: HomeAssistant, observations, text: str = "") -> None: +def raise_mirrored_entries( + hass: HomeAssistant, observations: list[Observation], text: str = "" +) -> None: """If there are mirrored entries, the user is probably using a workaround for a patched bug.""" if len(observations) != 2: return - true_sums_1: bool = ( - round( - observations[0]["prob_given_true"] + observations[1]["prob_given_true"], 1 - ) - == 1.0 - ) - false_sums_1: bool = ( - round( - observations[0]["prob_given_false"] + observations[1]["prob_given_false"], 1 - ) - == 1.0 - ) - same_states: bool = observations[0]["platform"] == observations[1]["platform"] - if true_sums_1 & false_sums_1 & same_states: + if observations[0].is_mirror(observations[1]): issue_registry.async_create_issue( hass, DOMAIN, @@ -39,7 +29,7 @@ def raise_mirrored_entries(hass: HomeAssistant, observations, text: str = "") -> # Should deprecate in some future version (2022.10 at time of writing) & make prob_given_false required in schemas. -def raise_no_prob_given_false(hass: HomeAssistant, observation, text: str) -> None: +def raise_no_prob_given_false(hass: HomeAssistant, text: str) -> None: """In previous 2022.9 and earlier, prob_given_false was optional and had a default version.""" issue_registry.async_create_issue( hass, diff --git a/homeassistant/components/bayesian/translations/ca.json b/homeassistant/components/bayesian/translations/ca.json new file mode 100644 index 0000000000000000000000000000000000000000..97d9d377885bf7761d431d1e88365aca83b506d0 --- /dev/null +++ b/homeassistant/components/bayesian/translations/ca.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "La integraci\u00f3 bayesiana ara tamb\u00e9 actualitza la probabilitat si l'observat `to_state`, `above', `below` o `value_template` retorna `Fals` en lloc de nom\u00e9s `Cert`. Per tant, ja no cal tenir entrades duplicades i complement\u00e0ries per a cada estat binari. Pots eliminar l'entrada duplicada de `{entity}`.", + "title": "Es necessita una correcci\u00f3 manual YAML per a Bayesian" + }, + "no_prob_given_false": { + "description": "A la integraci\u00f3 bayesiana, `prob_given_false` \u00e9s ara una variable de configuraci\u00f3 necess\u00e0ria (no hi havia cap motiu per al valor predeterminat anterior). Afegeix-la a `configuration.yaml` a `bayesian/ {entity}`. Les observacions s'ignoraran mentre no ho afegeixis.", + "title": "Es necessita afegir configuraci\u00f3 manual YAML per a Bayesian" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/de.json b/homeassistant/components/bayesian/translations/de.json new file mode 100644 index 0000000000000000000000000000000000000000..2c3cfa28f5a89adc904921e7242d03fd1c04fdf9 --- /dev/null +++ b/homeassistant/components/bayesian/translations/de.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "Die Bayes'sche Integration aktualisiert nun auch die Wahrscheinlichkeit, wenn das beobachtete \u201eto_state\u201c, \u201eabove\u201c, \u201ebelow\u201c oder \u201evalue_template\u201c zu \u201eFalse\u201c und nicht nur zu \u201eTrue\u201c ausgewertet wird. Es ist also nicht l\u00e4nger erforderlich, doppelte, komplement\u00e4re Eintr\u00e4ge f\u00fcr jeden bin\u00e4ren Zustand zu haben. Bitte entferne den gespiegelten Eintrag f\u00fcr ` {entity} `.", + "title": "Manuelle YAML-Korrektur f\u00fcr Bayes erforderlich" + }, + "no_prob_given_false": { + "description": "In der Bayes'schen Integration ist `prob_given_false` jetzt eine erforderliche Konfigurationsvariable, da es keine mathematische Begr\u00fcndung f\u00fcr den vorherigen Standardwert gab. Bitte f\u00fcge dies deiner `configuration.yml` f\u00fcr `bayesian/ {entity} ` hinzu. Diese Beobachtungen werden ignoriert, bis du dies tust.", + "title": "Manuelle YAML-Erg\u00e4nzung f\u00fcr Bayes erforderlich" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/el.json b/homeassistant/components/bayesian/translations/el.json new file mode 100644 index 0000000000000000000000000000000000000000..56e143bd03bb60da9017f0368ee849d714f9f2d0 --- /dev/null +++ b/homeassistant/components/bayesian/translations/el.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "\u0397 Bayesian \u03b5\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7\u03c4\u03ce\u03c1\u03b1 \u03b5\u03bd\u03b7\u03bc\u03b5\u03c1\u03ce\u03bd\u03b5\u03b9 \u03b5\u03c0\u03af\u03c3\u03b7\u03c2 \u03c4\u03b7\u03bd \u03c0\u03b9\u03b8\u03b1\u03bd\u03cc\u03c4\u03b7\u03c4\u03b1 \u03b5\u03ac\u03bd \u03c4\u03bf \u03c0\u03b1\u03c1\u03b1\u03c4\u03b7\u03c1\u03bf\u03cd\u03bc\u03b5\u03bd\u03bf \"to_state\", \"bove\", \"power\" \u03ae \"value_template\" \u03b1\u03be\u03b9\u03bf\u03bb\u03bf\u03b3\u03b7\u03b8\u03b5\u03af \u03c3\u03b5 \"False\" \u03ba\u03b1\u03b9 \u03cc\u03c7\u03b9 \u03bc\u03cc\u03bd\u03bf \u03c3\u03b5 \"True\". \u0395\u03c0\u03bf\u03bc\u03ad\u03bd\u03c9\u03c2, \u03b4\u03b5\u03bd \u03b1\u03c0\u03b1\u03b9\u03c4\u03b5\u03af\u03c4\u03b1\u03b9 \u03c0\u03bb\u03ad\u03bf\u03bd \u03bd\u03b1 \u03c5\u03c0\u03ac\u03c1\u03c7\u03bf\u03c5\u03bd \u03b4\u03b9\u03c0\u03bb\u03ad\u03c2, \u03c3\u03c5\u03bc\u03c0\u03bb\u03b7\u03c1\u03c9\u03bc\u03b1\u03c4\u03b9\u03ba\u03ad\u03c2 \u03b5\u03b3\u03b3\u03c1\u03b1\u03c6\u03ad\u03c2 \u03b3\u03b9\u03b1 \u03ba\u03ac\u03b8\u03b5 \u03b4\u03c5\u03b1\u03b4\u03b9\u03ba\u03ae \u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7. \u039a\u03b1\u03c4\u03b1\u03c1\u03b3\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03b1\u03bd\u03c4\u03b9\u03ba\u03b1\u03c4\u03bf\u03c0\u03c4\u03c1\u03b9\u03c3\u03bc\u03ad\u03bd\u03b7 \u03ba\u03b1\u03c4\u03b1\u03c7\u03ce\u03c1\u03b9\u03c3\u03b7 \u03b3\u03b9\u03b1 \" {entity} \".", + "title": "\u0391\u03c0\u03b1\u03b9\u03c4\u03b5\u03af\u03c4\u03b1\u03b9 \u03bc\u03b7 \u03b1\u03c5\u03c4\u03cc\u03bc\u03b1\u03c4\u03b7 \u03b5\u03c0\u03b9\u03b4\u03b9\u03cc\u03c1\u03b8\u03c9\u03c3\u03b7 YAML \u03b3\u03b9\u03b1 Bayesian" + }, + "no_prob_given_false": { + "description": "\u03a3\u03c4\u03b7\u03bd \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 Bayesian \u03b7 \u03bc\u03b5\u03c4\u03b1\u03b2\u03bb\u03b7\u03c4\u03ae `prob_given_false` \u03b5\u03af\u03bd\u03b1\u03b9 \u03c4\u03ce\u03c1\u03b1 \u03bc\u03b9\u03b1 \u03c5\u03c0\u03bf\u03c7\u03c1\u03b5\u03c9\u03c4\u03b9\u03ba\u03ae \u03bc\u03b5\u03c4\u03b1\u03b2\u03bb\u03b7\u03c4\u03ae \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7\u03c2, \u03ba\u03b1\u03b8\u03ce\u03c2 \u03b4\u03b5\u03bd \u03c5\u03c0\u03ae\u03c1\u03c7\u03b5 \u03bc\u03b1\u03b8\u03b7\u03bc\u03b1\u03c4\u03b9\u03ba\u03ae \u03bb\u03bf\u03b3\u03b9\u03ba\u03ae \u03b3\u03b9\u03b1 \u03c4\u03b7\u03bd \u03c0\u03c1\u03bf\u03b7\u03b3\u03bf\u03cd\u03bc\u03b5\u03bd\u03b7 \u03c0\u03c1\u03bf\u03b5\u03c0\u03b9\u03bb\u03b5\u03b3\u03bc\u03ad\u03bd\u03b7 \u03c4\u03b9\u03bc\u03ae. \u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03c0\u03c1\u03bf\u03c3\u03b8\u03ad\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c3\u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf `configuration.yml` \u03b3\u03b9\u03b1 \u03c4\u03bf `bayesian/{entity}`. \u0391\u03c5\u03c4\u03ad\u03c2 \u03bf\u03b9 \u03c0\u03b1\u03c1\u03b1\u03c4\u03b7\u03c1\u03ae\u03c3\u03b5\u03b9\u03c2 \u03b8\u03b1 \u03b1\u03b3\u03bd\u03bf\u03b7\u03b8\u03bf\u03cd\u03bd \u03bc\u03ad\u03c7\u03c1\u03b9 \u03bd\u03b1 \u03c4\u03bf \u03ba\u03ac\u03bd\u03b5\u03c4\u03b5.", + "title": "\u0391\u03c0\u03b1\u03b9\u03c4\u03b5\u03af\u03c4\u03b1\u03b9 \u03bc\u03b7 \u03b1\u03c5\u03c4\u03cc\u03bc\u03b1\u03c4\u03b7 \u03c0\u03c1\u03bf\u03c3\u03b8\u03ae\u03ba\u03b7 YAML \u03b3\u03b9\u03b1 Bayesian" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/en.json b/homeassistant/components/bayesian/translations/en.json index f95e153d9865d34a7455a91b1ac60ee3739372f4..e408467205cac6bceb83d61f95361f1ad5818d1f 100644 --- a/homeassistant/components/bayesian/translations/en.json +++ b/homeassistant/components/bayesian/translations/en.json @@ -1,12 +1,12 @@ { - "issues": { - "manual_migration": { - "description": "The Bayesian integration now also updates the probability if the observed `to_state`, `above`, `below`, or `value_template` evaluates to `False` rather than only `True`. So it is no longer required to have duplicate, complementary entries for each binary state. Please remove the mirrored entry for `{entity}`.", - "title": "Manual YAML fix required for Bayesian" - }, - "no_prob_given_false": { - "description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yml` for `bayesian/{entity}`. These observations will be ignored until you do.", - "title": "Manual YAML addition required for Bayesian" + "issues": { + "manual_migration": { + "description": "The Bayesian integration now also updates the probability if the observed `to_state`, `above`, `below`, or `value_template` evaluates to `False` rather than only `True`. So it is no longer required to have duplicate, complementary entries for each binary state. Please remove the mirrored entry for `{entity}`.", + "title": "Manual YAML fix required for Bayesian" + }, + "no_prob_given_false": { + "description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yml` for `bayesian/{entity}`. These observations will be ignored until you do.", + "title": "Manual YAML addition required for Bayesian" + } } - } -} +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/es.json b/homeassistant/components/bayesian/translations/es.json new file mode 100644 index 0000000000000000000000000000000000000000..dd38daee74b148c874033d6c3002e715f20a87a4 --- /dev/null +++ b/homeassistant/components/bayesian/translations/es.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "La integraci\u00f3n Bayesian ahora tambi\u00e9n actualiza la probabilidad si el 'to_state', 'above', 'below' o 'value_template' observado se eval\u00faa como 'False' en lugar de solo 'True'. Por lo tanto ya no es necesario tener entradas complementarias duplicadas para cada estado binario. Por favor, elimina la entrada duplicada para `{entity}`.", + "title": "Es necesario corregir manualmente el YAML para Bayesian" + }, + "no_prob_given_false": { + "description": "En la integraci\u00f3n Bayesian `prob_given_false` ahora es una variable de configuraci\u00f3n requerida ya que no hab\u00eda una justificaci\u00f3n matem\u00e1tica para el valor predeterminado anterior. Por favor, a\u00f1ade esto a tu `configuration.yml` para `bayesian/{entity}`. Estas observaciones se ignorar\u00e1n hasta que lo hagas.", + "title": "Es necesario realizar una adici\u00f3n manual al YAML de Bayesian" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/et.json b/homeassistant/components/bayesian/translations/et.json new file mode 100644 index 0000000000000000000000000000000000000000..80c3c24edec8d0a89a89cbf4018d1a47c4ab1039 --- /dev/null +++ b/homeassistant/components/bayesian/translations/et.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "Bayesiani sidumine v\u00e4rskendab n\u00fc\u00fcd ka t\u00f5en\u00e4osust kui vaadeldud \"oleku_seisund\", \"\u00fcleval\", \"alla\" v\u00f5i \"v\u00e4\u00e4rtusmall\" v\u00e4\u00e4rtus on \"V\u00e4\u00e4r\", mitte ainult \"True\". Seega ei n\u00f5uta enam iga binaaroleku jaoks dubleerivaid \u00fcksteist t\u00e4iendavaid kirjeid. Eemalda \u00fcksuse ` {entity} ` peegelkirje.", + "title": "Bayesiani jaoks on vajalik k\u00e4sitsi YAML-i muutmine" + }, + "no_prob_given_false": { + "description": "Bayesiani sidumises on 'prob_given_false' n\u00fc\u00fcd n\u00f5utav konfiguratsioonimuutuja, kuna eelmisel vaikev\u00e4\u00e4rtusel polnud matemaatilist p\u00f5hjendust. Palun lisa see oma faili `configuration.yaml' 'bayesian/ {entity} ' jaoks. Neid t\u00e4helepanekuid eiratakse seni, kuni seda teed.", + "title": "Bayesiani jaoks on vajalik k\u00e4sitsi YAML-i lisamine" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/hu.json b/homeassistant/components/bayesian/translations/hu.json new file mode 100644 index 0000000000000000000000000000000000000000..de97169d84a4f64cfde00764a57649dc494464e2 --- /dev/null +++ b/homeassistant/components/bayesian/translations/hu.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "A Bayesian integr\u00e1ci\u00f3 mostant\u00f3l akkor is friss\u00edti a val\u00f3sz\u00edn\u0171s\u00e9get, ha a megfigyelt `to_state`, `above`, `below` vagy `value_template` \u00e9rt\u00e9ke nem csak `True`, hanem `False`. \u00cdgy m\u00e1r nem sz\u00fcks\u00e9ges, hogy minden egyes bin\u00e1ris \u00e1llapothoz duplik\u00e1lt, egym\u00e1st kieg\u00e9sz\u00edt\u0151 bejegyz\u00e9sek legyenek. `{entity}` duplik\u00e1lt bejegyz\u00e9s\u00e9t most m\u00e1r elt\u00e1vol\u00edthatja.", + "title": "K\u00e9zi YAML jav\u00edt\u00e1s sz\u00fcks\u00e9ges a Bayesian-hoz" + }, + "no_prob_given_false": { + "description": "A Bayesian integr\u00e1ci\u00f3ban a `prob_given_false` mostant\u00f3l k\u00f6telez\u0151 konfigur\u00e1ci\u00f3s v\u00e1ltoz\u00f3, mivel a kor\u00e1bbi alap\u00e9rtelmezett \u00e9rt\u00e9knek nem volt matematikai alapja. K\u00e9rem, adja hozz\u00e1 ezt a `configuration.yml` f\u00e1jlhoz a `bayesian/{entity}`-hez. Ezek az esem\u00e9nyek mindaddig figyelmen k\u00edv\u00fcl maradnak, am\u00edg ezt v\u00e9gzi el.", + "title": "K\u00e9zi YAML-kieg\u00e9sz\u00edt\u00e9s sz\u00fcks\u00e9ges a Bayesian-hoz" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/id.json b/homeassistant/components/bayesian/translations/id.json new file mode 100644 index 0000000000000000000000000000000000000000..6c9673d4a34d068945119a74ee1f515082b90192 --- /dev/null +++ b/homeassistant/components/bayesian/translations/id.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "Integrasi Bayesian sekarang juga memperbarui probabilitas jika nilai `to_state`, `above`, `below`, atau `value_template` yang diamati dievaluasi menjadi `False` dan bukan hanya `True`. Jadi, tidak lagi diperlukan duplikat entri pelengkap untuk setiap status biner. Hapus entri cerminan untuk `{entity}`.", + "title": "Perbaikan YAML manual diperlukan untuk integrasi Bayesian" + }, + "no_prob_given_false": { + "description": "Pada integrasi Bayesian nilai `prob_given_false` sekarang merupakan variabel konfigurasi yang diperlukan karena tidak ada alasan matematis untuk nilai default sebelumnya. Tambahkan ini ke `configuration.yml` untuk `bayesian/{entity}`. Pengamatan ini akan diabaikan hingga Anda melakukannya.", + "title": "Penambahan YAML manual diperlukan untuk integrasi Bayesian" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/it.json b/homeassistant/components/bayesian/translations/it.json new file mode 100644 index 0000000000000000000000000000000000000000..0b162649e4c55b3d189c23e368d6a50d28c9fba9 --- /dev/null +++ b/homeassistant/components/bayesian/translations/it.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "L'integrazione bayesiana ora aggiorna anche la probabilit\u00e0 se l'osservato `to_state`, `above`, `below` o `value_template` restituisce `False` piuttosto che solo `True`. Quindi non \u00e8 pi\u00f9 necessario avere voci duplicate e complementari per ogni stato binario. Rimuovere la voce duplicata per `{entity}`.", + "title": "Correzione YAML manuale richiesta per Bayesian" + }, + "no_prob_given_false": { + "description": "Nell'integrazione bayesiana `prob_given_false` \u00e8 ora una variabile di configurazione richiesta in quanto non vi era alcuna logica matematica per il precedente valore predefinito. Aggiungilo al tuo `configuration.yml` per `bayesian/{entity}`. Queste osservazioni saranno ignorate fino a quando non lo farai.", + "title": "Aggiunta manuale YAML richiesta per Bayesian" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/no.json b/homeassistant/components/bayesian/translations/no.json new file mode 100644 index 0000000000000000000000000000000000000000..600e30346d342e6137e82ed6a080dc957f187585 --- /dev/null +++ b/homeassistant/components/bayesian/translations/no.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "Den Bayesianske integrasjonen oppdaterer n\u00e5 ogs\u00e5 sannsynligheten hvis den observerte `til_tilstand`, `over`, `under` eller `verdimal` evalueres til `False` i stedet for bare `Sant`. S\u00e5 det er ikke lenger n\u00f8dvendig \u00e5 ha dupliserte, komplement\u00e6re oppf\u00f8ringer for hver bin\u00e6r tilstand. Vennligst fjern den speilvendte oppf\u00f8ringen for ` {entity} `.", + "title": "Manuell YAML-fix kreves for Bayesian" + }, + "no_prob_given_false": { + "description": "I den Bayesianske integrasjonen er 'prob_given_false' n\u00e5 en n\u00f8dvendig konfigurasjonsvariabel siden det ikke var noen matematisk begrunnelse for den forrige standardverdien. Vennligst legg dette til i `configuration.yml` for `bayesian/ {entity} `. Disse observasjonene vil bli ignorert til du gj\u00f8r det.", + "title": "Manuell YAML-tilsetning kreves for Bayesian" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/pl.json b/homeassistant/components/bayesian/translations/pl.json new file mode 100644 index 0000000000000000000000000000000000000000..f9dfa69a457c43f0c77309edbd8c97a480e3fc9f --- /dev/null +++ b/homeassistant/components/bayesian/translations/pl.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "Integracja bayesowska aktualizuje teraz r\u00f3wnie\u017c prawdopodobie\u0144stwo, je\u015bli obserwowane warto\u015bci \u201eto_state\u201d, \u201eabove\u201d, \u201ebelow\u201d lub \u201evalue_template\u201d maj\u0105 warto\u015b\u0107 \u201eFalse\u201d, a nie tylko \u201eTrue\u201d. Dzi\u0119ki temu nie jest ju\u017c wymagane posiadanie zduplikowanych, uzupe\u0142niaj\u0105cych si\u0119 wpis\u00f3w dla ka\u017cdego stanu binarnego. Usu\u0144 lustrzany wpis dla `{entity}`.", + "title": "Wymagana r\u0119czna poprawa wpisu YAML dla integracji bayesowskiej" + }, + "no_prob_given_false": { + "description": "W integracji bayesowskiej `prob_given_false` jest teraz wymagan\u0105 zmienn\u0105 konfiguracyjn\u0105, poniewa\u017c nie by\u0142o matematycznego uzasadnienia dla poprzedniej warto\u015bci domy\u015blnej. Prosz\u0119 doda\u0107 to do pliku `configuration.yml` dla `bayesian/{entity}`. Te obserwacje b\u0119d\u0105 ignorowane, dop\u00f3ki tego nie zrobisz.", + "title": "Wymagane r\u0119czne dodanie wpisu YAML dla integracji bayesowskiej" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/pt-BR.json b/homeassistant/components/bayesian/translations/pt-BR.json new file mode 100644 index 0000000000000000000000000000000000000000..f0e758e40a953795e8a569a19a2e0c14dc929eb6 --- /dev/null +++ b/homeassistant/components/bayesian/translations/pt-BR.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "A integra\u00e7\u00e3o Bayesiana agora tamb\u00e9m atualiza a probabilidade se o observado `to_state`, `above`, `below` ou `value_template` for avaliado como `False` em vez de apenas `True`. Portanto, n\u00e3o \u00e9 mais necess\u00e1rio ter entradas duplicadas e complementares para cada estado bin\u00e1rio. Por favor, remova a entrada espelhada para `{entity}`.", + "title": "Corre\u00e7\u00e3o manual de YAML \u00e9 necess\u00e1ria para Bayesian" + }, + "no_prob_given_false": { + "description": "Na integra\u00e7\u00e3o Bayesiana, `prob_given_false` agora \u00e9 uma vari\u00e1vel de configura\u00e7\u00e3o necess\u00e1ria, pois n\u00e3o havia l\u00f3gica matem\u00e1tica para o valor padr\u00e3o anterior. Por favor, adicione isso ao seu `configuration.yml` para `bayesian/ {entity} `. Essas observa\u00e7\u00f5es ser\u00e3o ignoradas at\u00e9 que voc\u00ea o fa\u00e7a.", + "title": "Adi\u00e7\u00e3o YAML manual necess\u00e1ria para Bayesian" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/ru.json b/homeassistant/components/bayesian/translations/ru.json new file mode 100644 index 0000000000000000000000000000000000000000..0c99a16aecf32204e3c77ceed20aa983c0ed7526 --- /dev/null +++ b/homeassistant/components/bayesian/translations/ru.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f Bayesian \u0442\u0435\u043f\u0435\u0440\u044c \u0442\u0430\u043a\u0436\u0435 \u043e\u0431\u043d\u043e\u0432\u043b\u044f\u0435\u0442 \u0432\u0435\u0440\u043e\u044f\u0442\u043d\u043e\u0441\u0442\u044c, \u0435\u0441\u043b\u0438 \u043d\u0430\u0431\u043b\u044e\u0434\u0430\u0435\u043c\u044b\u0435 `to_state`, `above`, `below` \u0438\u043b\u0438 `value_template` \u043e\u0446\u0435\u043d\u0438\u0432\u0430\u044e\u0442\u0441\u044f \u043a\u0430\u043a `False`, \u0430 \u043d\u0435 \u0442\u043e\u043b\u044c\u043a\u043e `True`. \u0422\u0430\u043a\u0438\u043c \u043e\u0431\u0440\u0430\u0437\u043e\u043c, \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0434\u0443\u0431\u043b\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u0437\u0430\u043f\u0438\u0441\u0438 \u0434\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0431\u0438\u043d\u0430\u0440\u043d\u043e\u0433\u043e \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f. \u0423\u0434\u0430\u043b\u0438\u0442\u0435 \u0437\u0435\u0440\u043a\u0430\u043b\u044c\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c \u0434\u043b\u044f `{entity}`.", + "title": "\u0414\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 Bayesian \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u0438\u0441\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e YAML" + }, + "no_prob_given_false": { + "description": "\u0412 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 Bayesian `prob_given_false` \u0442\u0435\u043f\u0435\u0440\u044c \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0439 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438, \u043f\u043e\u0441\u043a\u043e\u043b\u044c\u043a\u0443 \u0434\u043b\u044f \u043f\u0440\u0435\u0434\u044b\u0434\u0443\u0449\u0435\u0433\u043e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u043d\u0435 \u0431\u044b\u043b\u043e \u043c\u0430\u0442\u0435\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u043e\u0431\u043e\u0441\u043d\u043e\u0432\u0430\u043d\u0438\u044f. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0434\u043e\u0431\u0430\u0432\u044c\u0442\u0435 \u044d\u0442\u043e \u0432 \u0441\u0432\u043e\u0439 `configuration.yml` \u0434\u043b\u044f `bayesian/{entity}`. \u042d\u0442\u0438 \u043d\u0430\u0431\u043b\u044e\u0434\u0435\u043d\u0438\u044f \u0431\u0443\u0434\u0443\u0442 \u0438\u0433\u043d\u043e\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f, \u043f\u043e\u043a\u0430 \u0412\u044b \u043d\u0435 \u0441\u0434\u0435\u043b\u0430\u0435\u0442\u0435 \u044d\u0442\u043e.", + "title": "\u0414\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 Bayesian \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0432\u0440\u0443\u0447\u043d\u0443\u044e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e YAML" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/sv.json b/homeassistant/components/bayesian/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..59038d408d91303a351f294c2242d1be970d57dc --- /dev/null +++ b/homeassistant/components/bayesian/translations/sv.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "Den Bayesianska integrationen uppdaterar nu ocks\u00e5 sannolikheten om den observerade `till_tillst\u00e5nd`, `\u00f6ver`, `under` eller `v\u00e4rde_mall` utv\u00e4rderas till `False` snarare \u00e4n bara `Sant`. S\u00e5 det \u00e4r inte l\u00e4ngre n\u00f6dv\u00e4ndigt att ha dubbla, kompletterande poster f\u00f6r varje bin\u00e4rt tillst\u00e5nd. Ta bort den speglade posten f\u00f6r ` {entity} `.", + "title": "Manuell YAML-korrigering kr\u00e4vs f\u00f6r Bayesian" + }, + "no_prob_given_false": { + "description": "I den Bayesianska integrationen \u00e4r 'prob_given_false' nu en obligatorisk konfigurationsvariabel eftersom det inte fanns n\u00e5gon matematisk motivering f\u00f6r det tidigare standardv\u00e4rdet. V\u00e4nligen l\u00e4gg till detta i din `configuration.yml` f\u00f6r `bayesian/ {entity} `. Dessa observationer kommer att ignoreras tills du g\u00f6r det.", + "title": "Manuellt YAML-till\u00e4gg kr\u00e4vs f\u00f6r Bayesian" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/tr.json b/homeassistant/components/bayesian/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..976e1d15b945e85f1c4fa6c4635424194c025e48 --- /dev/null +++ b/homeassistant/components/bayesian/translations/tr.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "Bayesian entegrasyonu art\u0131k, g\u00f6zlemlenen \"durum\", \"yukar\u0131da\", \"a\u015fa\u011f\u0131da\" veya \"de\u011fer_\u015fablonu\" yaln\u0131zca \"Do\u011fru\" yerine \"Yanl\u0131\u015f\" olarak de\u011ferlendirilirse olas\u0131l\u0131\u011f\u0131 da g\u00fcnceller. Bu nedenle, art\u0131k her ikili durum i\u00e7in yinelenen, tamamlay\u0131c\u0131 giri\u015flere sahip olmak gerekmez. L\u00fctfen ` {entity} ` i\u00e7in yans\u0131t\u0131lm\u0131\u015f giri\u015fi kald\u0131r\u0131n.", + "title": "Bayesian i\u00e7in manuel YAML d\u00fczeltmesi gerekli" + }, + "no_prob_given_false": { + "description": "Bayesian entegrasyonunda 'prob_given_false', \u00f6nceki varsay\u0131lan de\u011fer i\u00e7in matematiksel bir gerek\u00e7e olmad\u0131\u011f\u0131 i\u00e7in art\u0131k gerekli bir yap\u0131land\u0131rma de\u011fi\u015fkenidir. L\u00fctfen bunu \"bayesian/ {entity} \" i\u00e7in \"configuration.yml\" dosyan\u0131za ekleyin. Bu g\u00f6zlemler siz yapana kadar yok say\u0131lacakt\u0131r.", + "title": "Bayesian i\u00e7in manuel YAML eklemesi gerekiyor" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bayesian/translations/zh-Hant.json b/homeassistant/components/bayesian/translations/zh-Hant.json new file mode 100644 index 0000000000000000000000000000000000000000..c56dfa6fcbec923e70efba8e97b7daf78332d6b0 --- /dev/null +++ b/homeassistant/components/bayesian/translations/zh-Hant.json @@ -0,0 +1,12 @@ +{ + "issues": { + "manual_migration": { + "description": "\u5047\u5982\u767c\u73fe\u5230 `to_state`\u3001`above`\u3001`below` \u6216 `value_template` \u8a55\u4f30\u70ba `False` \u800c\u975e\u53ea\u662f `True`\uff0cBayesian \u8c9d\u5f0f\u7d71\u8a08\u6574\u5408\u4e5f\u6703\u9032\u884c\u66f4\u65b0\u6982\u7387\u3002 \u56e0\u6b64\u4e0d\u518d\u9700\u8981\u70ba\u6bcf\u500b\u4e8c\u9032\u4f4d\u611f\u6e2c\u5668\u63d0\u4f9b\u91cd\u8907\u3001\u88dc\u5145\u5be6\u9ad4\u3002\u8acb\u79fb\u9664 `{entity}` \u7684\u93e1\u50cf\u5be6\u9ad4\u3002", + "title": "\u9700\u8981\u65bc YAML \u624b\u52d5\u4fee\u6b63 Bayesian \u8c9d\u5f0f\u7d71\u8a08" + }, + "no_prob_given_false": { + "description": "\u65bc Bayesian \u8c9d\u5f0f\u7d71\u8a08\u6574\u5408\u4e4b `prob_given_false`\u3001\u7531\u65bc\u5148\u524d\u7684\u9810\u8a2d\u503c\u4e26\u6c92\u6709\u6578\u5b78\u539f\u7406\u3001\u56e0\u6b64\u73fe\u5728\u5fc5\u9808\u8a2d\u5b9a\u8b8a\u6578\u3002\u8acb\u65bc `configuration.yml` \u4e2d\u65b0\u589e\u4ee5\u7372\u5f97 `bayesian/{entity}`\u3002\u5728\u9032\u884c\u4fee\u6b63\u524d\u3001\u4efb\u4f55\u89c0\u5bdf\u7d50\u679c\u5c07\u88ab\u5ffd\u7565\u3002", + "title": "\u9700\u8981\u65bc YAML \u624b\u52d5\u6dfb\u52a0 Bayesian \u8c9d\u5f0f\u7d71\u8a08" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/bg.json b/homeassistant/components/binary_sensor/translations/bg.json index 621625cb457adecda04b1a5286cb2bd426792f55..603d64418cdd0d5c3d6738e0a707e1179cbd4095 100644 --- a/homeassistant/components/binary_sensor/translations/bg.json +++ b/homeassistant/components/binary_sensor/translations/bg.json @@ -113,6 +113,10 @@ "off": "\u041d\u043e\u0440\u043c\u0430\u043b\u043d\u0430", "on": "\u0418\u0437\u0442\u043e\u0449\u0435\u043d\u0430" }, + "battery_charging": { + "off": "\u041d\u0435 \u0441\u0435 \u0437\u0430\u0440\u0435\u0436\u0434\u0430", + "on": "\u0417\u0430\u0440\u0435\u0436\u0434\u0430\u043d\u0435" + }, "cold": { "off": "\u041d\u043e\u0440\u043c\u0430\u043b\u043d\u043e", "on": "\u0421\u0442\u0443\u0434\u0435\u043d\u043e" diff --git a/homeassistant/components/binary_sensor/translations/hu.json b/homeassistant/components/binary_sensor/translations/hu.json index ad5d5d932546ab3bd74e1e314e29f1a00a9bc39c..5d30570a8aa252342a975f6d3cf691fd9484e6c0 100644 --- a/homeassistant/components/binary_sensor/translations/hu.json +++ b/homeassistant/components/binary_sensor/translations/hu.json @@ -194,7 +194,7 @@ }, "presence": { "off": "T\u00e1vol", - "on": "Jelen" + "on": "Otthon" }, "problem": { "off": "OK", diff --git a/homeassistant/components/binary_sensor/translations/id.json b/homeassistant/components/binary_sensor/translations/id.json index e01ee10cc3c9b86a48a360bab0a759a314a17f4f..c5ef3775d5230ec4435913ae0ed3c11141561c5c 100644 --- a/homeassistant/components/binary_sensor/translations/id.json +++ b/homeassistant/components/binary_sensor/translations/id.json @@ -217,7 +217,7 @@ "on": "Terdeteksi" }, "update": { - "off": "Diperbarui", + "off": "Terbaru", "on": "Pembaruan tersedia" }, "vibration": { diff --git a/homeassistant/components/binary_sensor/translations/is.json b/homeassistant/components/binary_sensor/translations/is.json index 846e4ba186059d7dbe31fcd6797df7970134bb28..ac2c9901cadfe1d9f418fbbcdabdd8dc967bee8e 100644 --- a/homeassistant/components/binary_sensor/translations/is.json +++ b/homeassistant/components/binary_sensor/translations/is.json @@ -1,4 +1,28 @@ { + "device_automation": { + "condition_type": { + "is_co": "{entity_name} skynja\u00f0i kolm\u00f3noxi\u00f0", + "is_gas": "{entity_name} skynja\u00f0i gas", + "is_light": "{entity_name} skynja\u00f0i lj\u00f3s", + "is_motion": "{entity_name} skynja\u00f0i hreyfingu", + "is_no_motion": "{entity_name} er ekki a\u00f0 skynja hreyfingu", + "is_no_smoke": "{entity_name} er ekki a\u00f0 skynja reyk", + "is_not_open": "{entity_name} er loku\u00f0", + "is_problem": "{entity_name} skynja\u00f0i vandam\u00e1l", + "is_smoke": "{entity_name} skynja\u00f0i reyk", + "is_sound": "{entity_name} skynja\u00f0i hlj\u00f3\u00f0", + "is_tampered": "{entity_name} skynja\u00f0i fikt", + "is_vibration": "{entity_name} skynja\u00f0i titring" + }, + "trigger_type": { + "gas": "{entity_name} byrja\u00f0i a\u00f0 skynja gas", + "motion": "{entity_name} byrja\u00f0i a\u00f0 skynja hreyfingu", + "no_motion": "{entity_name} h\u00e6tti a\u00f0 skynja hreyfingu", + "not_opened": "{entity_name} loku\u00f0", + "opened": "{entity_name} opnu\u00f0", + "problem": "{entity_name} byrja\u00f0i a\u00f0 skynja vandam\u00e1l" + } + }, "state": { "_": { "off": "Sl\u00f6kkt", diff --git a/homeassistant/components/binary_sensor/translations/pl.json b/homeassistant/components/binary_sensor/translations/pl.json index d27d6443b55bf46da59e256f8a5b7ace981e5a73..e81344436c8109b59e722d4938ead13e0e40ff76 100644 --- a/homeassistant/components/binary_sensor/translations/pl.json +++ b/homeassistant/components/binary_sensor/translations/pl.json @@ -106,18 +106,18 @@ } }, "device_class": { - "co": "tlenek_w\u0119gla", - "cold": "zimno", + "co": "tlenek w\u0119gla", + "cold": "ch\u0142\u00f3d", "gas": "gaz", - "heat": "gor\u0105co", - "moisture": "wilgotno\u015b\u0107", + "heat": "ciep\u0142o", + "moisture": "wilgo\u0107", "motion": "ruch", "occupancy": "obecno\u015b\u0107", "power": "zasilanie", "problem": "problem", "smoke": "dym", "sound": "d\u017awi\u0119k", - "vibration": "wibracja" + "vibration": "wibracje" }, "state": { "_": { diff --git a/homeassistant/components/blebox/__init__.py b/homeassistant/components/blebox/__init__.py index 0f4bd1c14906a26de163e32aa80f6ff4b45b2bc8..1a7c810465248f6625737ce4b5cfe93c013ed640 100644 --- a/homeassistant/components/blebox/__init__.py +++ b/homeassistant/components/blebox/__init__.py @@ -1,5 +1,6 @@ """The BleBox devices integration.""" import logging +from typing import Generic, TypeVar from blebox_uniapi.box import Box from blebox_uniapi.error import Error @@ -8,7 +9,7 @@ from blebox_uniapi.session import ApiHost from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, 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 from homeassistant.helpers.entity import DeviceInfo, Entity @@ -18,7 +19,7 @@ from .const import DEFAULT_SETUP_TIMEOUT, DOMAIN, PRODUCT _LOGGER = logging.getLogger(__name__) PLATFORMS = [ - Platform.AIR_QUALITY, + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.COVER, @@ -29,6 +30,8 @@ PLATFORMS = [ PARALLEL_UPDATES = 0 +_FeatureT = TypeVar("_FeatureT", bound=Feature) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up BleBox devices from a config entry.""" @@ -65,26 +68,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -@callback -def create_blebox_entities( - hass, config_entry, async_add_entities, entity_klass, entity_type -): - """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)) - - async_add_entities(entities, True) - - -class BleBoxEntity(Entity): +class BleBoxEntity(Entity, Generic[_FeatureT]): """Implements a common class for entities representing a BleBox feature.""" - def __init__(self, feature: Feature) -> None: + def __init__(self, feature: _FeatureT) -> None: """Initialize a BleBox entity.""" self._feature = feature self._attr_name = feature.full_name diff --git a/homeassistant/components/blebox/air_quality.py b/homeassistant/components/blebox/air_quality.py deleted file mode 100644 index daadbc831b6082219b7cc52692798928f800d67f..0000000000000000000000000000000000000000 --- a/homeassistant/components/blebox/air_quality.py +++ /dev/null @@ -1,43 +0,0 @@ -"""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 -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, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up a BleBox air quality entity.""" - create_blebox_entities( - hass, config_entry, async_add_entities, BleBoxAirQualityEntity, "air_qualities" - ) - - -class BleBoxAirQualityEntity(BleBoxEntity, AirQualityEntity): - """Representation of a BleBox air quality feature.""" - - _attr_icon = "mdi:blur" - - @property - def particulate_matter_0_1(self): - """Return the particulate matter 0.1 level.""" - return self._feature.pm1 - - @property - def particulate_matter_2_5(self): - """Return the particulate matter 2.5 level.""" - return self._feature.pm2_5 - - @property - def particulate_matter_10(self): - """Return the particulate matter 10 level.""" - return self._feature.pm10 diff --git a/homeassistant/components/blebox/binary_sensor.py b/homeassistant/components/blebox/binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..7eb6fd1e5a28fcce44e9457eb4a91a515527c498 --- /dev/null +++ b/homeassistant/components/blebox/binary_sensor.py @@ -0,0 +1,55 @@ +"""BleBox binary sensor entities.""" + +from blebox_uniapi.binary_sensor import BinarySensor as BinarySensorFeature +from blebox_uniapi.box import Box + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN, PRODUCT, BleBoxEntity + +BINARY_SENSOR_TYPES = ( + BinarySensorEntityDescription( + key="moisture", + device_class=BinarySensorDeviceClass.MOISTURE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a BleBox entry.""" + + product: Box = hass.data[DOMAIN][config_entry.entry_id][PRODUCT] + entities = [ + BleBoxBinarySensorEntity(feature, description) + for feature in product.features.get("binary_sensors", []) + for description in BINARY_SENSOR_TYPES + if description.key == feature.device_class + ] + async_add_entities(entities, True) + + +class BleBoxBinarySensorEntity(BleBoxEntity[BinarySensorFeature], BinarySensorEntity): + """Representation of a BleBox binary sensor feature.""" + + def __init__( + self, feature: BinarySensorFeature, description: BinarySensorEntityDescription + ) -> None: + """Initialize a BleBox binary sensor feature.""" + super().__init__(feature) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return the state.""" + return self._feature.state diff --git a/homeassistant/components/blebox/button.py b/homeassistant/components/blebox/button.py index e9ceaac2dc75adc845612786cd4e0ba831103f29..93a5a4e0c5ac090ff2b39336f08fcadeeab2b127 100644 --- a/homeassistant/components/blebox/button.py +++ b/homeassistant/components/blebox/button.py @@ -1,12 +1,16 @@ """BleBox button entities implementation.""" from __future__ import annotations +from blebox_uniapi.box import Box +import blebox_uniapi.button + from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BleBoxEntity, create_blebox_entities +from . import BleBoxEntity +from .const import DOMAIN, PRODUCT async def async_setup_entry( @@ -15,20 +19,23 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a BleBox button entry.""" - create_blebox_entities( - hass, config_entry, async_add_entities, BleBoxButtonEntity, "buttons" - ) + product: Box = hass.data[DOMAIN][config_entry.entry_id][PRODUCT] + + entities = [ + BleBoxButtonEntity(feature) for feature in product.features.get("buttons", []) + ] + async_add_entities(entities, True) -class BleBoxButtonEntity(BleBoxEntity, ButtonEntity): +class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity): """Representation of BleBox buttons.""" - def __init__(self, feature): + def __init__(self, feature: blebox_uniapi.button.Button) -> None: """Initialize a BleBox button feature.""" super().__init__(feature) self._attr_icon = self.get_icon() - def get_icon(self): + def get_icon(self) -> str | None: """Return icon for endpoint.""" if "up" in self._feature.query_string: return "mdi:arrow-up-circle" @@ -40,7 +47,7 @@ class BleBoxButtonEntity(BleBoxEntity, ButtonEntity): return "mdi:arrow-up-circle" if "close" in self._feature.query_string: return "mdi:arrow-down-circle" - return "" + return None async def async_press(self) -> None: """Handle the button press.""" diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index 65920b170c5950cf202687a61a59ce7186dc4b84..9b632c9aceb6fb98cff683cde772b78295107130 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -2,6 +2,9 @@ from datetime import timedelta from typing import Any +from blebox_uniapi.box import Box +import blebox_uniapi.climate + from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -13,7 +16,8 @@ from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BleBoxEntity, create_blebox_entities +from . import BleBoxEntity +from .const import DOMAIN, PRODUCT SCAN_INTERVAL = timedelta(seconds=5) @@ -24,13 +28,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a BleBox climate entity.""" + product: Box = hass.data[DOMAIN][config_entry.entry_id][PRODUCT] - create_blebox_entities( - hass, config_entry, async_add_entities, BleBoxClimateEntity, "climates" - ) + entities = [ + BleBoxClimateEntity(feature) for feature in product.features.get("climates", []) + ] + async_add_entities(entities, True) -class BleBoxClimateEntity(BleBoxEntity, ClimateEntity): +class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity): """Representation of a BleBox climate feature (saunaBox).""" _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index 882356f1a77da92b7f70bbc00dd2f3a68e22c745..80e2fbd30e7bad0c85a9407b1342d462603ad1a1 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -3,6 +3,9 @@ from __future__ import annotations from typing import Any +from blebox_uniapi.box import Box +import blebox_uniapi.cover + from homeassistant.components.cover import ( ATTR_POSITION, CoverDeviceClass, @@ -14,7 +17,8 @@ from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_O from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BleBoxEntity, create_blebox_entities +from . import BleBoxEntity +from .const import DOMAIN, PRODUCT BLEBOX_TO_COVER_DEVICE_CLASSES = { "gate": CoverDeviceClass.GATE, @@ -44,16 +48,17 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a BleBox entry.""" - - create_blebox_entities( - hass, config_entry, async_add_entities, BleBoxCoverEntity, "covers" - ) + product: Box = hass.data[DOMAIN][config_entry.entry_id][PRODUCT] + entities = [ + BleBoxCoverEntity(feature) for feature in product.features.get("covers", []) + ] + async_add_entities(entities, True) -class BleBoxCoverEntity(BleBoxEntity, CoverEntity): +class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): """Representation of a BleBox cover feature.""" - def __init__(self, feature): + def __init__(self, feature: blebox_uniapi.cover.Cover) -> None: """Initialize a BleBox cover feature.""" super().__init__(feature) self._attr_device_class = BLEBOX_TO_COVER_DEVICE_CLASSES[feature.device_class] diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index c1245d52fec62e4bdd982e2b0edbda2905b10266..b138aae15b729fd20b3987fd02f005b670a27730 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -5,6 +5,7 @@ from datetime import timedelta import logging from typing import Any +from blebox_uniapi.box import Box import blebox_uniapi.light from blebox_uniapi.light import BleboxColorMode @@ -23,7 +24,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BleBoxEntity, create_blebox_entities +from . import BleBoxEntity +from .const import DOMAIN, PRODUCT _LOGGER = logging.getLogger(__name__) @@ -36,10 +38,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a BleBox entry.""" - - create_blebox_entities( - hass, config_entry, async_add_entities, BleBoxLightEntity, "lights" - ) + product: Box = hass.data[DOMAIN][config_entry.entry_id][PRODUCT] + entities = [ + BleBoxLightEntity(feature) for feature in product.features.get("lights", []) + ] + async_add_entities(entities, True) COLOR_MODE_MAP = { @@ -53,11 +56,9 @@ COLOR_MODE_MAP = { } -class BleBoxLightEntity(BleBoxEntity, LightEntity): +class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): """Representation of BleBox lights.""" - _feature: blebox_uniapi.light.Light - def __init__(self, feature: blebox_uniapi.light.Light) -> None: """Initialize a BleBox light.""" super().__init__(feature) diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index 49d44db8f01a7a52973c3f894123c7c8645526ba..78c7186eb31177bf3fe161f8ac142d9f836187c0 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -3,7 +3,7 @@ "name": "BleBox devices", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blebox", - "requirements": ["blebox_uniapi==2.0.2"], + "requirements": ["blebox_uniapi==2.1.3"], "codeowners": ["@bbx-a", "@riokuu"], "iot_class": "local_polling", "loggers": ["blebox_uniapi"] diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index 663af970e3e5c919a078c8427166c80703805cf9..471f8c6eb86feb949b3ae300d48fd74ef852e4b4 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -1,15 +1,43 @@ """BleBox sensor entities.""" -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity + +from blebox_uniapi.box import Box +import blebox_uniapi.sensor + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BleBoxEntity, create_blebox_entities +from . import BleBoxEntity +from .const import DOMAIN, PRODUCT -BLEBOX_TO_UNIT_MAP = {"celsius": TEMP_CELSIUS} - -BLEBOX_TO_SENSOR_DEVICE_CLASS = {"temperature": SensorDeviceClass.TEMPERATURE} +SENSOR_TYPES = ( + SensorEntityDescription( + key="pm1", + device_class=SensorDeviceClass.PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key="pm2_5", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key="pm10", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + ), +) async def async_setup_entry( @@ -18,22 +46,29 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a BleBox entry.""" - - create_blebox_entities( - hass, config_entry, async_add_entities, BleBoxSensorEntity, "sensors" - ) + product: Box = hass.data[DOMAIN][config_entry.entry_id][PRODUCT] + entities = [ + BleBoxSensorEntity(feature, description) + for feature in product.features.get("sensors", []) + for description in SENSOR_TYPES + if description.key == feature.device_class + ] + async_add_entities(entities, True) -class BleBoxSensorEntity(BleBoxEntity, SensorEntity): +class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEntity): """Representation of a BleBox sensor feature.""" - def __init__(self, feature): + def __init__( + self, + feature: blebox_uniapi.sensor.BaseSensor, + description: SensorEntityDescription, + ) -> None: """Initialize a BleBox sensor feature.""" super().__init__(feature) - self._attr_native_unit_of_measurement = BLEBOX_TO_UNIT_MAP[feature.unit] - self._attr_device_class = BLEBOX_TO_SENSOR_DEVICE_CLASS[feature.device_class] + self.entity_description = description @property def native_value(self): """Return the state.""" - return self._feature.current + return self._feature.native_value diff --git a/homeassistant/components/blebox/switch.py b/homeassistant/components/blebox/switch.py index 5ae37d6b34d3145e1f81863ad49bec029aca00cd..d7145ebb620bd2aacdbf20604a84c99def5267bd 100644 --- a/homeassistant/components/blebox/switch.py +++ b/homeassistant/components/blebox/switch.py @@ -2,12 +2,16 @@ from datetime import timedelta from typing import Any +from blebox_uniapi.box import Box +import blebox_uniapi.switch + from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BleBoxEntity, create_blebox_entities +from . import BleBoxEntity +from .const import DOMAIN, PRODUCT SCAN_INTERVAL = timedelta(seconds=5) @@ -18,15 +22,17 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a BleBox switch entity.""" - create_blebox_entities( - hass, config_entry, async_add_entities, BleBoxSwitchEntity, "switches" - ) + product: Box = hass.data[DOMAIN][config_entry.entry_id][PRODUCT] + entities = [ + BleBoxSwitchEntity(feature) for feature in product.features.get("switches", []) + ] + async_add_entities(entities, True) -class BleBoxSwitchEntity(BleBoxEntity, SwitchEntity): +class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity): """Representation of a BleBox switch feature.""" - def __init__(self, feature): + def __init__(self, feature: blebox_uniapi.switch.Switch) -> None: """Initialize a BleBox switch feature.""" super().__init__(feature) self._attr_device_class = SwitchDeviceClass.SWITCH diff --git a/homeassistant/components/blebox/translations/nb.json b/homeassistant/components/blebox/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/blebox/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blink/translations/bg.json b/homeassistant/components/blink/translations/bg.json index 32c84eeb1dc3b824d5d49199e4740cf1a4ca8e0d..60e7c86f621b9c4e354d501a310d3b387537a547 100644 --- a/homeassistant/components/blink/translations/bg.json +++ b/homeassistant/components/blink/translations/bg.json @@ -14,7 +14,7 @@ "2fa": "\u0414\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u0435\u043d \u043a\u043e\u0434" }, "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u041f\u0418\u041d \u043a\u043e\u0434\u0430, \u0438\u0437\u043f\u0440\u0430\u0442\u0435\u043d \u043d\u0430 \u0432\u0430\u0448\u0438\u044f \u0438\u043c\u0435\u0439\u043b", - "title": "\u0414\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + "title": "\u0414\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" }, "user": { "data": { diff --git a/homeassistant/components/blink/translations/nb.json b/homeassistant/components/blink/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/blink/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bliss_automation/manifest.json b/homeassistant/components/bliss_automation/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..ca0ae5c7fdf4208da26117dd5ef5f655d7c6673b --- /dev/null +++ b/homeassistant/components/bliss_automation/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "bliss_automation", + "name": "Bliss Automation", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/bloc_blinds/manifest.json b/homeassistant/components/bloc_blinds/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..a0e318e2b2f44a69634b3df61106cc85115cde9d --- /dev/null +++ b/homeassistant/components/bloc_blinds/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "bloc_blinds", + "name": "Bloc Blinds", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py index 4feb7a9fa6a21f863c7b74194bc92eb784a5905e..6c65987ef5780c87573a62e5ecbfca38a6910130 100644 --- a/homeassistant/components/blockchain/sensor.py +++ b/homeassistant/components/blockchain/sensor.py @@ -8,7 +8,7 @@ from pyblockchain import get_balance, validate_address import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -16,14 +16,10 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by blockchain.com" - CONF_ADDRESSES = "addresses" DEFAULT_NAME = "Bitcoin Balance" -ICON = "mdi:currency-btc" - SCAN_INTERVAL = timedelta(minutes=5) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -42,8 +38,8 @@ def setup_platform( ) -> None: """Set up the Blockchain.com sensors.""" - addresses = config[CONF_ADDRESSES] - name = config[CONF_NAME] + addresses: list[str] = config[CONF_ADDRESSES] + name: str = config[CONF_NAME] for address in addresses: if not validate_address(address): @@ -56,11 +52,11 @@ def setup_platform( class BlockchainSensor(SensorEntity): """Representation of a Blockchain.com sensor.""" - _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} - _attr_icon = ICON + _attr_attribution = "Data provided by blockchain.com" + _attr_icon = "mdi:currency-btc" _attr_native_unit_of_measurement = "BTC" - def __init__(self, name, addresses): + def __init__(self, name: str, addresses: list[str]) -> None: """Initialize the sensor.""" self._attr_name = name self.addresses = addresses diff --git a/homeassistant/components/bloomsky/__init__.py b/homeassistant/components/bloomsky/__init__.py index ed2ce1ebc70ffef77dd6b5bf29474268c8d102f1..5b069cacdb33b0b442c444733644b85bff0b24d3 100644 --- a/homeassistant/components/bloomsky/__init__.py +++ b/homeassistant/components/bloomsky/__init__.py @@ -12,6 +12,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle +from homeassistant.util.unit_system import METRIC_SYSTEM _LOGGER = logging.getLogger(__name__) @@ -33,7 +34,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: api_key = config[DOMAIN][CONF_API_KEY] try: - bloomsky = BloomSky(api_key, hass.config.units.is_metric) + bloomsky = BloomSky(api_key, hass.config.units is METRIC_SYSTEM) except RuntimeError: return False diff --git a/homeassistant/components/bluemaestro/translations/hu.json b/homeassistant/components/bluemaestro/translations/hu.json index 97fbb5b940835353812ebfe6bb39907626ddb6e9..4668ffea41696296cf59192c6561163e058b2a49 100644 --- a/homeassistant/components/bluemaestro/translations/hu.json +++ b/homeassistant/components/bluemaestro/translations/hu.json @@ -15,7 +15,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index f77a2bed9a48d1137b1866fa6aacd27c1be0fa08..ee81a583391cc4790e4013c0503585e24a4390db 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -51,7 +51,7 @@ class Blueprint: def __init__( self, - data: dict, + data: dict[str, Any], *, path: str | None = None, expected_domain: str | None = None, @@ -202,10 +202,10 @@ class DomainBlueprints: """Return the blueprint folder.""" return pathlib.Path(self.hass.config.path(BLUEPRINT_FOLDER, self.domain)) - @callback - def async_reset_cache(self) -> None: + async def async_reset_cache(self) -> None: """Reset the blueprint cache.""" - self._blueprints = {} + async with self._load_lock: + self._blueprints = {} def _load_blueprint(self, blueprint_path) -> Blueprint: """Load a blueprint.""" @@ -339,6 +339,10 @@ class DomainBlueprints: async def async_populate(self) -> None: """Create folder if it doesn't exist and populate with examples.""" + if self._blueprints: + # If we have already loaded some blueprint the blueprint folder must exist + return + integration = await loader.async_get_integration(self.hass, self.domain) def populate(): diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 0b84d1d08c26380a4d059e955c38a24da8eb6ef6..a9bcf5ded1c9b702ca5426bbb8eb0a095f1632f6 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -1,6 +1,8 @@ """Websocket API for blueprint.""" from __future__ import annotations +from typing import Any, cast + import async_timeout import voluptuous as vol @@ -31,12 +33,14 @@ def async_setup(hass: HomeAssistant): } ) @websocket_api.async_response -async def ws_list_blueprints(hass, connection, msg): +async def ws_list_blueprints( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """List available blueprints.""" - domain_blueprints: dict[str, models.DomainBlueprints] | None = hass.data.get( - DOMAIN, {} - ) - results = {} + domain_blueprints: dict[str, models.DomainBlueprints] = hass.data.get(DOMAIN, {}) + results: dict[str, Any] = {} if msg["domain"] not in domain_blueprints: connection.send_result(msg["id"], results) @@ -62,7 +66,11 @@ async def ws_list_blueprints(hass, connection, msg): } ) @websocket_api.async_response -async def ws_import_blueprint(hass, connection, msg): +async def ws_import_blueprint( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Import a blueprint.""" async with async_timeout.timeout(10): imported_blueprint = await importer.fetch_blueprint_from_url(hass, msg["url"]) @@ -96,15 +104,17 @@ async def ws_import_blueprint(hass, connection, msg): } ) @websocket_api.async_response -async def ws_save_blueprint(hass, connection, msg): +async def ws_save_blueprint( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Save a blueprint.""" path = msg["path"] domain = msg["domain"] - domain_blueprints: dict[str, models.DomainBlueprints] | None = hass.data.get( - DOMAIN, {} - ) + domain_blueprints: dict[str, models.DomainBlueprints] = hass.data.get(DOMAIN, {}) if domain not in domain_blueprints: connection.send_error( @@ -112,9 +122,8 @@ async def ws_save_blueprint(hass, connection, msg): ) try: - blueprint = models.Blueprint( - yaml.parse_yaml(msg["yaml"]), expected_domain=domain - ) + yaml_data = cast(dict[str, Any], yaml.parse_yaml(msg["yaml"])) + blueprint = models.Blueprint(yaml_data, expected_domain=domain) if "source_url" in msg: blueprint.update_metadata(source_url=msg["source_url"]) except HomeAssistantError as err: @@ -143,15 +152,17 @@ async def ws_save_blueprint(hass, connection, msg): } ) @websocket_api.async_response -async def ws_delete_blueprint(hass, connection, msg): +async def ws_delete_blueprint( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Delete a blueprint.""" path = msg["path"] domain = msg["domain"] - domain_blueprints: dict[str, models.DomainBlueprints] | None = hass.data.get( - DOMAIN, {} - ) + domain_blueprints: dict[str, models.DomainBlueprints] = hass.data.get(DOMAIN, {}) if domain not in domain_blueprints: connection.send_error( diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 23611e5bef50cf16b6d87fd82d8a3e9371f10eef..833050ba0891a1cef78b1a85c2d333b11d3c1663 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -38,6 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -210,7 +211,6 @@ class BluesoundPlayer(MediaPlayerEntity): self._polling_task = None # The actual polling task. self._name = name self._id = None - self._icon = None self._capture_items = [] self._services_items = [] self._preset_items = [] @@ -258,8 +258,6 @@ class BluesoundPlayer(MediaPlayerEntity): self._id = self._sync_status.get("@id", None) if not self._bluesound_device_name: self._bluesound_device_name = self._sync_status.get("@name", self.host) - if not self._icon: - self._icon = self._sync_status.get("@icon", self.host) if (master := self._sync_status.get("master")) is not None: self._is_master = False @@ -449,6 +447,11 @@ class BluesoundPlayer(MediaPlayerEntity): _LOGGER.info("Client connection error, marking %s as offline", self._name) raise + @property + def unique_id(self): + """Return an unique ID.""" + return f"{format_mac(self._sync_status['@mac'])}-{self.port}" + async def async_trigger_sync_on_all(self): """Trigger sync status update on all devices.""" _LOGGER.debug("Trigger sync status on all devices") @@ -678,11 +681,6 @@ class BluesoundPlayer(MediaPlayerEntity): """Return the device name as returned by the device.""" return self._bluesound_device_name - @property - def icon(self): - """Return the icon of the device.""" - return self._icon - @property def source_list(self): """List of available input sources.""" diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index f175b01b7980a21a213332fe499cdc9880fb8249..1d0b8824fb5beb7cf8fd996088e4c233ab380580 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -39,6 +39,7 @@ from .const import ( DATA_MANAGER, DEFAULT_ADDRESS, DOMAIN, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SOURCE_LOCAL, AdapterDetails, ) @@ -81,6 +82,7 @@ __all__ = [ "BluetoothCallback", "HaBluetoothConnector", "SOURCE_LOCAL", + "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index 37f049d3e0724e1351476850e8d159d6347e5010..ab26a0260f38ac7c950311e9ca28f522bb8e336f 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -3,13 +3,13 @@ from __future__ import annotations from collections.abc import Callable, Coroutine import logging -import time from typing import Any, Generic, TypeVar from bleak import BleakError from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer +from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_processor import PassiveBluetoothProcessorCoordinator @@ -94,7 +94,7 @@ class ActiveBluetoothProcessorCoordinator( """Return true if time to try and poll.""" poll_age: float | None = None if self._last_poll: - poll_age = time.monotonic() - self._last_poll + poll_age = monotonic_time_coarse() - self._last_poll return self._needs_poll_method(service_info, poll_age) async def _async_poll_data( @@ -124,7 +124,7 @@ class ActiveBluetoothProcessorCoordinator( self.last_poll_successful = False return finally: - self._last_poll = time.monotonic() + self._last_poll = monotonic_time_coarse() if not self.last_poll_successful: self.logger.debug("%s: Polling recovered") diff --git a/homeassistant/components/bluetooth/advertisement_tracker.py b/homeassistant/components/bluetooth/advertisement_tracker.py new file mode 100644 index 0000000000000000000000000000000000000000..f4577496e043e065ddb9b4a3246b32e2c9153a30 --- /dev/null +++ b/homeassistant/components/bluetooth/advertisement_tracker.py @@ -0,0 +1,68 @@ +"""The bluetooth integration advertisement tracker.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import callback + +from .models import BluetoothServiceInfoBleak + +ADVERTISING_TIMES_NEEDED = 16 + + +class AdvertisementTracker: + """Tracker to determine the interval that a device is advertising.""" + + def __init__(self) -> None: + """Initialize the tracker.""" + self.intervals: dict[str, float] = {} + self.sources: dict[str, str] = {} + self._timings: dict[str, list[float]] = {} + + @callback + def async_diagnostics(self) -> dict[str, dict[str, Any]]: + """Return diagnostics.""" + return { + "intervals": self.intervals, + "sources": self.sources, + "timings": self._timings, + } + + @callback + def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None: + """Collect timings for the tracker. + + For performance reasons, it is the responsibility of the + caller to check if the device already has an interval set or + the source has changed before calling this function. + """ + address = service_info.address + self.sources[address] = service_info.source + timings = self._timings.setdefault(address, []) + timings.append(service_info.time) + if len(timings) != ADVERTISING_TIMES_NEEDED: + return + + max_time_between_advertisements = timings[1] - timings[0] + for i in range(2, len(timings)): + time_between_advertisements = timings[i] - timings[i - 1] + if time_between_advertisements > max_time_between_advertisements: + max_time_between_advertisements = time_between_advertisements + + # We now know the maximum time between advertisements + self.intervals[address] = max_time_between_advertisements + del self._timings[address] + + @callback + def async_remove_address(self, address: str) -> None: + """Remove the tracker.""" + self.intervals.pop(address, None) + self.sources.pop(address, None) + self._timings.pop(address, None) + + @callback + def async_remove_source(self, source: str) -> None: + """Remove the tracker.""" + for address, tracked_source in list(self.sources.items()): + if tracked_source == source: + self.async_remove_address(address) diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index 4d4a096bb664dda87c8cbaf57375e333f5ac2d60..6d6751f6ac4941f8a26875c602fb789ea3b70887 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -31,11 +31,17 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 START_TIMEOUT = 15 -MAX_DBUS_SETUP_SECONDS = 5 - -# Anything after 30s is considered stale, we have buffer -# for start timeouts and execution time -STALE_ADVERTISEMENT_SECONDS: Final = 30 + START_TIMEOUT + MAX_DBUS_SETUP_SECONDS +# The maximum time between advertisements for a device to be considered +# stale when the advertisement tracker cannot determine the interval. +# +# We have to set this quite high as we don't know +# when devices fall out of the ESPHome device (and other non-local scanners)'s +# stack like we do with BlueZ so its safer to assume its available +# since if it does go out of range and it is in range +# of another device the timeout is much shorter and it will +# switch over to using that adapter anyways. +# +FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15 # We must recover before we hit the 180s mark @@ -45,9 +51,9 @@ STALE_ADVERTISEMENT_SECONDS: Final = 30 + START_TIMEOUT + MAX_DBUS_SETUP_SECONDS # to be # 180s Time when device is removed from stack # - 30s check interval -# - 20s scanner restart time * 2 +# - 30s scanner restart time * 2 # -SCANNER_WATCHDOG_TIMEOUT: Final = 110 +SCANNER_WATCHDOG_TIMEOUT: Final = 90 # How often to check if the scanner has reached # the SCANNER_WATCHDOG_TIMEOUT without seeing anything SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30) @@ -66,6 +72,3 @@ ADAPTER_ADDRESS: Final = "address" ADAPTER_SW_VERSION: Final = "sw_version" ADAPTER_HW_VERSION: Final = "hw_version" ADAPTER_PASSIVE_SCAN: Final = "passive_scan" - - -NO_RSSI_VALUE: Final = -1000 diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index f0152f5ae5eb177a0e23c1462bf7b6582bfc9f22..d29023acef78ea7a893c744a0b4cb7dcf33e0523 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -3,12 +3,14 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable +from dataclasses import replace from datetime import datetime, timedelta import itertools import logging from typing import TYPE_CHECKING, Any, Final from bleak.backends.scanner import AdvertisementDataCallback +from bleak_retry_connector import NO_RSSI_VALUE, RSSI_SWITCH_THRESHOLD from homeassistant import config_entries from homeassistant.core import ( @@ -19,12 +21,13 @@ from homeassistant.core import ( ) from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.dt import monotonic_time_coarse +from .advertisement_tracker import AdvertisementTracker from .const import ( ADAPTER_ADDRESS, ADAPTER_PASSIVE_SCAN, - NO_RSSI_VALUE, - STALE_ADVERTISEMENT_SECONDS, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, UNAVAILABLE_TRACK_SECONDS, AdapterDetails, ) @@ -58,57 +61,19 @@ APPLE_MFR_ID: Final = 76 APPLE_IBEACON_START_BYTE: Final = 0x02 # iBeacon (tilt_ble) APPLE_HOMEKIT_START_BYTE: Final = 0x06 # homekit_controller APPLE_DEVICE_ID_START_BYTE: Final = 0x10 # bluetooth_le_tracker +APPLE_HOMEKIT_NOTIFY_START_BYTE: Final = 0x11 # homekit_controller APPLE_START_BYTES_WANTED: Final = { APPLE_IBEACON_START_BYTE, APPLE_HOMEKIT_START_BYTE, + APPLE_HOMEKIT_NOTIFY_START_BYTE, APPLE_DEVICE_ID_START_BYTE, } -RSSI_SWITCH_THRESHOLD = 6 +MONOTONIC_TIME: Final = monotonic_time_coarse _LOGGER = logging.getLogger(__name__) -def _prefer_previous_adv( - old: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak -) -> bool: - """Prefer previous advertisement if it is better.""" - if new.time - old.time > STALE_ADVERTISEMENT_SECONDS: - # If the old advertisement is stale, any new advertisement is preferred - if new.source != old.source: - _LOGGER.debug( - "%s (%s): Switching from %s[%s] to %s[%s] (time elapsed:%s > stale seconds:%s)", - new.advertisement.local_name, - new.device.address, - old.source, - old.connectable, - new.source, - new.connectable, - new.time - old.time, - STALE_ADVERTISEMENT_SECONDS, - ) - return False - if new.device.rssi - RSSI_SWITCH_THRESHOLD > (old.device.rssi or NO_RSSI_VALUE): - # If new advertisement is RSSI_SWITCH_THRESHOLD more, the new one is preferred - if new.source != old.source: - _LOGGER.debug( - "%s (%s): Switching from %s[%s] to %s[%s] (new rssi:%s - threshold:%s > old rssi:%s)", - new.advertisement.local_name, - new.device.address, - old.source, - old.connectable, - new.source, - new.connectable, - new.device.rssi, - RSSI_SWITCH_THRESHOLD, - old.device.rssi, - ) - return False - # If the source is the different, the old one is preferred because its - # not stale and its RSSI_SWITCH_THRESHOLD less than the new one - return old.source != new.source - - def _dispatch_bleak_callback( callback: AdvertisementDataCallback | None, filters: dict[str, set[str]], @@ -142,22 +107,27 @@ class BluetoothManager: """Init bluetooth manager.""" self.hass = hass self._integration_matcher = integration_matcher - self._cancel_unavailable_tracking: list[CALLBACK_TYPE] = [] + self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None + + self._advertisement_tracker = AdvertisementTracker() + self._unavailable_callbacks: dict[ str, list[Callable[[BluetoothServiceInfoBleak], None]] ] = {} self._connectable_unavailable_callbacks: dict[ str, list[Callable[[BluetoothServiceInfoBleak], None]] ] = {} + self._callback_index = BluetoothCallbackMatcherIndex() self._bleak_callbacks: list[ tuple[AdvertisementDataCallback, dict[str, set[str]]] ] = [] - self._history: dict[str, BluetoothServiceInfoBleak] = {} + self._all_history: dict[str, BluetoothServiceInfoBleak] = {} self._connectable_history: dict[str, BluetoothServiceInfoBleak] = {} self._non_connectable_scanners: list[BaseHaScanner] = [] self._connectable_scanners: list[BaseHaScanner] = [] self._adapters: dict[str, AdapterDetails] = {} + self._sources: set[str] = set() @property def supports_passive_scan(self) -> bool: @@ -187,9 +157,10 @@ class BluetoothManager: service_info.as_dict() for service_info in self._connectable_history.values() ], - "history": [ - service_info.as_dict() for service_info in self._history.values() + "all_history": [ + service_info.as_dict() for service_info in self._all_history.values() ], + "advertisement_tracker": self._advertisement_tracker.async_diagnostics(), } def _find_adapter_by_address(self, address: str) -> str | None: @@ -220,7 +191,7 @@ class BluetoothManager: # Everything is connectable so it fall into both # buckets since the host system can only provide # connectable devices - self._history = history.copy() + self._all_history = history.copy() self._connectable_history = history.copy() self.async_setup_unavailable_tracking() @@ -229,37 +200,36 @@ class BluetoothManager: """Stop the Bluetooth integration at shutdown.""" _LOGGER.debug("Stopping bluetooth manager") if self._cancel_unavailable_tracking: - for cancel in self._cancel_unavailable_tracking: - cancel() - self._cancel_unavailable_tracking.clear() + self._cancel_unavailable_tracking() + self._cancel_unavailable_tracking = None uninstall_multiple_bleak_catcher() - async def async_get_devices_by_address( + @hass_callback + def async_get_discovered_devices_and_advertisement_data_by_address( self, address: str, connectable: bool - ) -> list[BLEDevice]: - """Get devices by address.""" + ) -> list[tuple[BLEDevice, AdvertisementData]]: + """Get devices and advertisement_data by address.""" types_ = (True,) if connectable else (True, False) return [ - device - for device in await asyncio.gather( - *( - scanner.async_get_device_by_address(address) - for type_ in types_ - for scanner in self._get_scanners_by_type(type_) - ) + device_advertisement_data + for device_advertisement_data in ( + scanner.discovered_devices_and_advertisement_data.get(address) + for type_ in types_ + for scanner in self._get_scanners_by_type(type_) ) - if device is not None + if device_advertisement_data is not None ] @hass_callback - def async_all_discovered_devices(self, connectable: bool) -> Iterable[BLEDevice]: - """Return all of discovered devices from all the scanners including duplicates.""" + def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: + """Return all of discovered addresses from all the scanners including duplicates.""" yield from itertools.chain.from_iterable( - scanner.discovered_devices for scanner in self._get_scanners_by_type(True) + scanner.discovered_devices_and_advertisement_data + for scanner in self._get_scanners_by_type(True) ) if not connectable: yield from itertools.chain.from_iterable( - scanner.discovered_devices + scanner.discovered_devices_and_advertisement_data for scanner in self._get_scanners_by_type(False) ) @@ -274,54 +244,104 @@ class BluetoothManager: @hass_callback def async_setup_unavailable_tracking(self) -> None: """Set up the unavailable tracking.""" - self._async_setup_unavailable_tracking(True) - self._async_setup_unavailable_tracking(False) + self._cancel_unavailable_tracking = async_track_time_interval( + self.hass, + self._async_check_unavailable, + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), + ) @hass_callback - def _async_setup_unavailable_tracking(self, connectable: bool) -> None: - """Set up the unavailable tracking.""" - unavailable_callbacks = self._get_unavailable_callbacks_by_type(connectable) - history = self._get_history_by_type(connectable) - - @hass_callback - def _async_check_unavailable(now: datetime) -> None: - """Watch for unavailable devices.""" - history_set = set(history) - active_addresses = { - device.address - for device in self.async_all_discovered_devices(connectable) - } - disappeared = history_set.difference(active_addresses) + def _async_check_unavailable(self, now: datetime) -> None: + """Watch for unavailable devices and cleanup state history.""" + monotonic_now = MONOTONIC_TIME() + connectable_history = self._connectable_history + all_history = self._all_history + tracker = self._advertisement_tracker + intervals = tracker.intervals + + for connectable in (True, False): + unavailable_callbacks = self._get_unavailable_callbacks_by_type(connectable) + history = connectable_history if connectable else all_history + disappeared = set(history).difference( + self._async_all_discovered_addresses(connectable) + ) for address in disappeared: + if not connectable: + # + # For non-connectable devices we also check the device has exceeded + # the advertising interval before we mark it as unavailable + # since it may have gone to sleep and since we do not need an active connection + # to it we can only determine its availability by the lack of advertisements + # + if advertising_interval := intervals.get(address): + time_since_seen = monotonic_now - all_history[address].time + if time_since_seen <= advertising_interval: + continue + + # The second loop (connectable=False) is responsible for removing + # the device from all the interval tracking since it is no longer + # available for both connectable and non-connectable + tracker.async_remove_address(address) + service_info = history.pop(address) + if not (callbacks := unavailable_callbacks.get(address)): continue + for callback in callbacks: try: callback(service_info) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in unavailable callback") - self._cancel_unavailable_tracking.append( - async_track_time_interval( - self.hass, - _async_check_unavailable, - timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), + def _prefer_previous_adv_from_different_source( + self, + old: BluetoothServiceInfoBleak, + new: BluetoothServiceInfoBleak, + ) -> bool: + """Prefer previous advertisement from a different source if it is better.""" + if new.time - old.time > ( + stale_seconds := self._advertisement_tracker.intervals.get( + new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS ) - ) + ): + # If the old advertisement is stale, any new advertisement is preferred + _LOGGER.debug( + "%s (%s): Switching from %s[%s] to %s[%s] (time elapsed:%s > stale seconds:%s)", + new.name, + new.address, + old.source, + old.connectable, + new.source, + new.connectable, + new.time - old.time, + stale_seconds, + ) + return False + if (new.rssi or NO_RSSI_VALUE) - RSSI_SWITCH_THRESHOLD > ( + old.rssi or NO_RSSI_VALUE + ): + # If new advertisement is RSSI_SWITCH_THRESHOLD more, the new one is preferred + _LOGGER.debug( + "%s (%s): Switching from %s[%s] to %s[%s] (new rssi:%s - threshold:%s > old rssi:%s)", + new.name, + new.address, + old.source, + old.connectable, + new.source, + new.connectable, + new.rssi, + RSSI_SWITCH_THRESHOLD, + old.rssi, + ) + return False + return True @hass_callback def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: """Handle a new advertisement from any scanner. Callbacks from all the scanners arrive here. - - In the future we will only process callbacks if - - - The device is not in the history - - The RSSI is above a certain threshold better than - than the source from the history or the timestamp - in the history is older than 180s """ # Pre-filter noisy apple devices as they can account for 20-35% of the @@ -337,18 +357,74 @@ class BluetoothManager: return device = service_info.device - connectable = service_info.connectable address = device.address - all_history = self._connectable_history if connectable else self._history - old_service_info = all_history.get(address) - if old_service_info and _prefer_previous_adv(old_service_info, service_info): - return + all_history = self._all_history + connectable = service_info.connectable + connectable_history = self._connectable_history - self._history[address] = service_info + source = service_info.source + # This logic is complex due to the many combinations of scanners that are supported. + # + # We need to handle multiple connectable and non-connectable scanners + # and we need to handle the case where a device is connectable on one scanner + # but not on another. + # + # The device may also be connectable only by a scanner that has worse signal strength + # than a non-connectable scanner. + # + # all_history - the history of all advertisements from all scanners with the best + # advertisement from each scanner + # connectable_history - the history of all connectable advertisements from all scanners + # with the best advertisement from each connectable scanner + # + if ( + (old_service_info := all_history.get(address)) + and source != old_service_info.source + and old_service_info.source in self._sources + and self._prefer_previous_adv_from_different_source( + old_service_info, service_info + ) + ): + # If we are rejecting the new advertisement and the device is connectable + # but not in the connectable history or the connectable source is the same + # as the new source, we need to add it to the connectable history + if connectable: + old_connectable_service_info = connectable_history.get(address) + if old_connectable_service_info and ( + # If its the same as the preferred source, we are done + # as we know we prefer the old advertisement + # from the check above + (old_connectable_service_info is old_service_info) + # If the old connectable source is different from the preferred + # source, we need to check it as well to see if we prefer + # the old connectable advertisement + or ( + source != old_connectable_service_info.source + and old_connectable_service_info.source in self._sources + and self._prefer_previous_adv_from_different_source( + old_connectable_service_info, service_info + ) + ) + ): + return + + connectable_history[address] = service_info + + return if connectable: - self._connectable_history[address] = service_info - # Bleak callbacks must get a connectable device + connectable_history[address] = service_info + + all_history[address] = service_info + + # Track advertisement intervals to determine when we need to + # switch adapters or mark a device as unavailable + tracker = self._advertisement_tracker + if (last_source := tracker.sources.get(address)) and last_source != source: + # Source changed, remove the old address from the tracker + tracker.async_remove_address(address) + if address not in tracker.intervals: + tracker.async_collect(service_info) # If the advertisement data is the same as the last time we saw it, we # don't need to do anything else. @@ -360,11 +436,13 @@ class BluetoothManager: ): return - source = service_info.source - if connectable: - # Bleak callbacks must get a connectable device - for callback_filters in self._bleak_callbacks: - _dispatch_bleak_callback(*callback_filters, device, advertisement_data) + is_connectable_by_any_source = address in self._connectable_history + if not connectable and is_connectable_by_any_source: + # Since we have a connectable path and our BleakClient will + # route any connection attempts to the connectable path, we + # mark the service_info as connectable so that the callbacks + # will be called and the device can be discovered. + service_info = replace(service_info, connectable=True) matched_domains = self._integration_matcher.match_domains(service_info) _LOGGER.debug( @@ -374,9 +452,14 @@ class BluetoothManager: advertisement_data, connectable, matched_domains, - device.rssi, + advertisement_data.rssi, ) + if is_connectable_by_any_source: + # Bleak callbacks must get a connectable device + for callback_filters in self._bleak_callbacks: + _dispatch_bleak_callback(*callback_filters, device, advertisement_data) + for match in self._callback_index.match_callbacks(service_info): callback = match[CALLBACK] try: @@ -438,15 +521,19 @@ class BluetoothManager: # immediately with the last packet so the subscriber can see the # device. all_history = self._get_history_by_type(connectable) - if ( - (address := callback_matcher.get(ADDRESS)) - and (service_info := all_history.get(address)) - and ble_device_matches(callback_matcher, service_info) - ): - try: - callback(service_info, BluetoothChange.ADVERTISEMENT) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in bluetooth callback") + service_infos: Iterable[BluetoothServiceInfoBleak] = [] + if address := callback_matcher.get(ADDRESS): + if service_info := all_history.get(address): + service_infos = [service_info] + else: + service_infos = all_history.values() + + for service_info in service_infos: + if ble_device_matches(callback_matcher, service_info): + try: + callback(service_info, BluetoothChange.ADVERTISEMENT) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in bluetooth callback") return _async_remove_callback @@ -486,27 +573,23 @@ class BluetoothManager: def _get_scanners_by_type(self, connectable: bool) -> list[BaseHaScanner]: """Return the scanners by type.""" - return ( - self._connectable_scanners - if connectable - else self._non_connectable_scanners - ) + if connectable: + return self._connectable_scanners + return self._non_connectable_scanners def _get_unavailable_callbacks_by_type( self, connectable: bool ) -> dict[str, list[Callable[[BluetoothServiceInfoBleak], None]]]: """Return the unavailable callbacks by type.""" - return ( - self._connectable_unavailable_callbacks - if connectable - else self._unavailable_callbacks - ) + if connectable: + return self._connectable_unavailable_callbacks + return self._unavailable_callbacks def _get_history_by_type( self, connectable: bool ) -> dict[str, BluetoothServiceInfoBleak]: """Return the history by type.""" - return self._connectable_history if connectable else self._history + return self._connectable_history if connectable else self._all_history def async_register_scanner( self, scanner: BaseHaScanner, connectable: bool @@ -515,9 +598,12 @@ class BluetoothManager: scanners = self._get_scanners_by_type(connectable) def _unregister_scanner() -> None: + self._advertisement_tracker.async_remove_source(scanner.source) scanners.remove(scanner) + self._sources.remove(scanner.source) scanners.append(scanner) + self._sources.add(scanner.source) return _unregister_scanner @hass_callback diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index d829b46574dc408892a82908fef72aaf9cc24cd8..89281323541cdbc2dcc41be65e4a8f6c46605c30 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -6,11 +6,11 @@ "after_dependencies": ["hassio"], "quality_scale": "internal", "requirements": [ - "bleak==0.18.1", - "bleak-retry-connector==2.1.3", + "bleak==0.19.1", + "bleak-retry-connector==2.8.2", "bluetooth-adapters==0.6.0", "bluetooth-auto-recovery==0.3.6", - "dbus-fast==1.24.0" + "dbus-fast==1.61.1" ], "codeowners": ["@bdraco"], "config_flow": true, diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 4ea44bcbe9b2a54ac21d6571b39bd5e76d36abc6..a63a704baf6c7b5d7920fa1c66a9536a8d563bcd 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -18,14 +18,12 @@ from bleak.backends.scanner import ( AdvertisementDataCallback, BaseBleakScanner, ) -from bleak_retry_connector import freshen_ble_device +from bleak_retry_connector import NO_RSSI_VALUE, freshen_ble_device -from homeassistant.core import CALLBACK_TYPE, callback as hass_callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.helpers.frame import report from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo -from .const import NO_RSSI_VALUE - if TYPE_CHECKING: from .manager import BluetoothManager @@ -105,14 +103,22 @@ class _HaWrappedBleakBackend: class BaseHaScanner: """Base class for Ha Scanners.""" + def __init__(self, hass: HomeAssistant, source: str) -> None: + """Initialize the scanner.""" + self.hass = hass + self.source = source + @property @abstractmethod def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" + @property @abstractmethod - async def async_get_device_by_address(self, address: str) -> BLEDevice | None: - """Get a device by address.""" + def discovered_devices_and_advertisement_data( + self, + ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: + """Return a list of discovered devices and their advertisement data.""" async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" @@ -122,7 +128,6 @@ class BaseHaScanner: { "name": device.name, "address": device.address, - "rssi": device.rssi, } for device in self.discovered_devices ], @@ -259,6 +264,7 @@ class HaBleakClientWrapper(BleakClient): self.__address = address_or_ble_device self.__disconnected_callback = disconnected_callback self.__timeout = timeout + self.__ble_device: BLEDevice | None = None self._backend: BaseBleakClient | None = None # type: ignore[assignment] @property @@ -278,14 +284,21 @@ class HaBleakClientWrapper(BleakClient): async def connect(self, **kwargs: Any) -> bool: """Connect to the specified GATT server.""" - if not self._backend: + if ( + not self._backend + or not self.__ble_device + or not self._async_get_backend_for_ble_device(self.__ble_device) + ): assert MANAGER is not None wrapped_backend = ( - self._async_get_backend() or await self._async_get_fallback_backend() + self._async_get_backend() or self._async_get_fallback_backend() ) - self._backend = wrapped_backend.client( + self.__ble_device = ( await freshen_ble_device(wrapped_backend.device) - or wrapped_backend.device, + or wrapped_backend.device + ) + self._backend = wrapped_backend.client( + self.__ble_device, disconnected_callback=self.__disconnected_callback, timeout=self.__timeout, hass=MANAGER.hass, @@ -326,7 +339,8 @@ class HaBleakClientWrapper(BleakClient): return None - async def _async_get_fallback_backend(self) -> _HaWrappedBleakBackend: + @hass_callback + def _async_get_fallback_backend(self) -> _HaWrappedBleakBackend: """Get a fallback backend for the given address.""" # # The preferred backend cannot currently connect the device @@ -337,13 +351,20 @@ class HaBleakClientWrapper(BleakClient): # assert MANAGER is not None address = self.__address - devices = await MANAGER.async_get_devices_by_address(address, True) - for ble_device in sorted( - devices, - key=lambda ble_device: ble_device.rssi or NO_RSSI_VALUE, + device_advertisement_datas = ( + MANAGER.async_get_discovered_devices_and_advertisement_data_by_address( + address, True + ) + ) + for device_advertisement_data in sorted( + device_advertisement_datas, + key=lambda device_advertisement_data: device_advertisement_data[1].rssi + or NO_RSSI_VALUE, reverse=True, ): - if backend := self._async_get_backend_for_ble_device(ble_device): + if backend := self._async_get_backend_for_ble_device( + device_advertisement_data[0] + ): return backend raise BleakError( diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index 9bc68059a7f166a22fb44a90428f2965ac3e36c5..6b23cae02183e763a66d12a112f0f7d1c1bfa7a1 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -6,7 +6,6 @@ from collections.abc import Callable from datetime import datetime import logging import platform -import time from typing import Any import async_timeout @@ -17,12 +16,12 @@ from bleak.backends.bluezdbus.advertisement_monitor import OrPattern from bleak.backends.bluezdbus.scanner import BlueZScannerArgs from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback -from bleak_retry_connector import get_device_by_adapter from dbus_fast import InvalidMessageError from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.dt import monotonic_time_coarse from homeassistant.util.package import is_docker_env from .const import ( @@ -36,7 +35,7 @@ from .models import BaseHaScanner, BluetoothScanningMode, BluetoothServiceInfoBl from .util import adapter_human_name, async_reset_adapter OriginalBleakScanner = bleak.BleakScanner -MONOTONIC_TIME = time.monotonic +MONOTONIC_TIME = monotonic_time_coarse # or_patterns is a workaround for the fact that passive scanning # needs at least one matcher to be set. The below matcher @@ -50,8 +49,6 @@ PASSIVE_SCANNER_ARGS = BlueZScannerArgs( _LOGGER = logging.getLogger(__name__) -MONOTONIC_TIME = time.monotonic - # If the adapter is in a stuck state the following errors are raised: NEED_RESET_ERRORS = [ "org.bluez.Error.Failed", @@ -130,7 +127,8 @@ class HaScanner(BaseHaScanner): address: str, ) -> None: """Init bluetooth discovery.""" - self.hass = hass + source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL + super().__init__(hass, source) self.mode = mode self.adapter = adapter self._start_stop_lock = asyncio.Lock() @@ -139,13 +137,19 @@ class HaScanner(BaseHaScanner): self._start_time = 0.0 self._callbacks: list[Callable[[BluetoothServiceInfoBleak], None]] = [] self.name = adapter_human_name(adapter, address) - self.source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" return self.scanner.discovered_devices + @property + def discovered_devices_and_advertisement_data( + self, + ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: + """Return a list of discovered devices and advertisement data.""" + return self.scanner.discovered_devices_and_advertisement_data + @hass_callback def async_setup(self) -> None: """Set up the scanner.""" @@ -153,16 +157,6 @@ class HaScanner(BaseHaScanner): self._async_detection_callback, self.mode, self.adapter ) - async def async_get_device_by_address(self, address: str) -> BLEDevice | None: - """Get a device by address.""" - if platform.system() == "Linux": - return await get_device_by_adapter(address, self.adapter) - # We don't have a fast version of this for MacOS yet - return next( - (device for device in self.discovered_devices if device.address == address), - None, - ) - async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" base_diag = await super().async_diagnostics() @@ -353,6 +347,12 @@ class HaScanner(BaseHaScanner): ) if time_since_last_detection < SCANNER_WATCHDOG_TIMEOUT: return + if self._start_stop_lock.locked(): + _LOGGER.debug( + "%s: Scanner is already restarting, deferring restart", + self.name, + ) + return _LOGGER.info( "%s: Bluetooth scanner has gone quiet for %ss, restarting", self.name, @@ -391,15 +391,11 @@ class HaScanner(BaseHaScanner): async def async_stop(self) -> None: """Stop bluetooth scanner.""" - async with self._start_stop_lock: - await self._async_stop() - - async def _async_stop(self) -> None: - """Cancel watchdog and bluetooth discovery under the lock.""" if self._cancel_watchdog: self._cancel_watchdog() self._cancel_watchdog = None - await self._async_stop_scanner() + async with self._start_stop_lock: + await self._async_stop_scanner() async def _async_stop_scanner(self) -> None: """Stop bluetooth discovery under the lock.""" diff --git a/homeassistant/components/bluetooth/translations/et.json b/homeassistant/components/bluetooth/translations/et.json index 5ada590decf6098106186dc74236cb7548d2771b..5579ab5b62d1a7ebaf8414d91483bac1604e3740 100644 --- a/homeassistant/components/bluetooth/translations/et.json +++ b/homeassistant/components/bluetooth/translations/et.json @@ -29,6 +29,12 @@ } } }, + "issues": { + "haos_outdated": { + "description": "Bluetoothi t\u00f6\u00f6kindluse ja j\u00f5udluse parandamiseks soovitame tungivalt v\u00e4rskendada Home Assistanti operatsioonis\u00fcsteemi versioonile 9.0 v\u00f5i uuemale.", + "title": "Uuenda operatsioonis\u00fcsteemile Home Assistant 9.0 v\u00f5i uuem" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/bluetooth/translations/hu.json b/homeassistant/components/bluetooth/translations/hu.json index 79dc32040318e94ee0745791effe0a576c789fad..e5b94f070eae9fe4cdc0c4ba70a9eb2d07e2f59a 100644 --- a/homeassistant/components/bluetooth/translations/hu.json +++ b/homeassistant/components/bluetooth/translations/hu.json @@ -25,7 +25,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } }, diff --git a/homeassistant/components/bluetooth/translations/nl.json b/homeassistant/components/bluetooth/translations/nl.json index c9db452612e007ea7eee1f50d044f96f8d2ccdc7..22ec504f2440fe945fb8b33c5aea2fbd65763174 100644 --- a/homeassistant/components/bluetooth/translations/nl.json +++ b/homeassistant/components/bluetooth/translations/nl.json @@ -12,6 +12,11 @@ "enable_bluetooth": { "description": "Wilt u Bluetooth instellen?" }, + "multiple_adapters": { + "data": { + "adapter": "Adapter" + } + }, "user": { "data": { "address": "Apparaat" diff --git a/homeassistant/components/bluetooth/translations/sv.json b/homeassistant/components/bluetooth/translations/sv.json index 4f1e43c1490533206a63c1842196cb294b0944ea..ee2d433fc1ceeb7891371934eeac2feeef3a0b45 100644 --- a/homeassistant/components/bluetooth/translations/sv.json +++ b/homeassistant/components/bluetooth/translations/sv.json @@ -29,6 +29,12 @@ } } }, + "issues": { + "haos_outdated": { + "description": "F\u00f6r att f\u00f6rb\u00e4ttra Bluetooths tillf\u00f6rlitlighet och prestanda rekommenderar vi starkt att du uppdaterar till version 9.0 eller senare av operativsystemet Home Assistant.", + "title": "Uppdatera till Home Assistant operativsystem 9.0 eller senare" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/bluetooth/translations/tr.json b/homeassistant/components/bluetooth/translations/tr.json index 2ffd8c80814079a95cb0c47c5cf61e8371cca0f8..787bc1fed947b9778cc1c25061ed24ab02a0fc81 100644 --- a/homeassistant/components/bluetooth/translations/tr.json +++ b/homeassistant/components/bluetooth/translations/tr.json @@ -29,6 +29,12 @@ } } }, + "issues": { + "haos_outdated": { + "description": "Bluetooth g\u00fcvenilirli\u011fini ve performans\u0131n\u0131 art\u0131rmak i\u00e7in Home Assistant \u0130\u015fletim Sisteminin 9.0 veya sonraki bir s\u00fcr\u00fcm\u00fcne g\u00fcncellemenizi \u00f6nemle tavsiye ederiz.", + "title": "Home Assistant \u0130\u015fletim Sistemi 9.0 veya \u00fczeri g\u00fcncelleme" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index 860428a6106efb85e4ea9599de435137fa316e70..181796d3d2d300de54403dfc1a7c7131734cbb87 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -2,11 +2,11 @@ from __future__ import annotations import platform -import time from bluetooth_auto_recovery import recover_adapter from homeassistant.core import callback +from homeassistant.util.dt import monotonic_time_coarse from .const import ( DEFAULT_ADAPTER_BY_PLATFORM, @@ -29,7 +29,7 @@ async def async_load_history_from_system() -> dict[str, BluetoothServiceInfoBlea bluez_dbus = BlueZDBusObjects() await bluez_dbus.load() - now = time.monotonic() + now = monotonic_time_coarse() return { address: BluetoothServiceInfoBleak( name=history.advertisement_data.local_name diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 2c8543bd72abe594ed33f4ef2c6233541d634f48..43beb2cbf819533f9aae90079d3f202496720fed 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -30,6 +30,7 @@ _LOGGER = logging.getLogger(__name__) ALLOWED_CONDITION_BASED_SERVICE_KEYS = { "BRAKE_FLUID", + "EMISSION_CHECK", "ENGINE_OIL", "OIL", "TIRE_WEAR_FRONT", diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 7de0f40e86b7e37f3bc3e4a632f5245cee95e237..c797de99859a927b831300c0b844a152ba61bdad 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -19,7 +19,6 @@ from homeassistant.const import LENGTH, PERCENTAGE, VOLUME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.util.unit_system import UnitSystem from . import BMWBaseEntity from .const import DOMAIN, UNIT_MAP @@ -137,7 +136,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the MyBMW sensors from config entry.""" - unit_system = hass.config.units coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[BMWSensor] = [] @@ -145,7 +143,7 @@ async def async_setup_entry( for vehicle in coordinator.account.vehicles: entities.extend( [ - BMWSensor(coordinator, vehicle, description, unit_system) + BMWSensor(coordinator, vehicle, description) for attribute_name in vehicle.available_attributes if (description := SENSOR_TYPES.get(attribute_name)) ] @@ -164,7 +162,6 @@ class BMWSensor(BMWBaseEntity, SensorEntity): coordinator: BMWDataUpdateCoordinator, vehicle: MyBMWVehicle, description: BMWSensorEntityDescription, - unit_system: UnitSystem, ) -> None: """Initialize BMW vehicle sensor.""" super().__init__(coordinator, vehicle) diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 6f57a23b079a226c969f17aec9a7a522dcb00fb2..32b76c6fcae032c6e7cc0e7aa9081f224a5c5dd1 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -274,8 +274,7 @@ async def async_setup_entry( ) entities.extend(device_entities) - if entities: - async_add_entities(entities) + async_add_entities(entities) class BondButtonEntity(BondEntity, ButtonEntity): diff --git a/homeassistant/components/bond/translations/nb.json b/homeassistant/components/bond/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/bond/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py index e76e562f9ef92f230b08cf68750b6a5d24723e49..25ab320a4c4640afd2f7dee9b94ed9537d86fce4 100644 --- a/homeassistant/components/bosch_shc/binary_sensor.py +++ b/homeassistant/components/bosch_shc/binary_sensor.py @@ -53,8 +53,7 @@ async def async_setup_entry( ) ) - if entities: - async_add_entities(entities) + async_add_entities(entities) class ShutterContactSensor(SHCEntity, BinarySensorEntity): diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py index 91dc361a23d8274063e3044efe118d2771637915..3f1a9eccb93d079f608ff665c127bfbad5b56c1c 100644 --- a/homeassistant/components/bosch_shc/cover.py +++ b/homeassistant/components/bosch_shc/cover.py @@ -36,8 +36,7 @@ async def async_setup_entry( ) ) - if entities: - async_add_entities(entities) + async_add_entities(entities) class ShutterControlCover(SHCEntity, CoverEntity): diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 331a5ebb5f39f26d11b8226285405df0f96fdd98..90fc44710a0f8cd6e5bbd20705bbfab5cfaf831f 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -4,7 +4,11 @@ from __future__ import annotations from boschshcpy import SHCSession from boschshcpy.device import SHCDevice -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, @@ -153,8 +157,7 @@ async def async_setup_entry( ) ) - if entities: - async_add_entities(entities) + async_add_entities(entities) class TemperatureSensor(SHCEntity, SensorEntity): @@ -317,6 +320,7 @@ class EnergySensor(SHCEntity, SensorEntity): """Representation of an SHC energy reporting sensor.""" _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 28b68ac091bb55bbb6b0ef77cdd1f7cfe0159ca8..1bc430c8d4aaaf799eed9902d8a119c65d86efe5 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -156,8 +156,7 @@ async def async_setup_entry( ) ) - if entities: - async_add_entities(entities) + async_add_entities(entities) class SHCSwitch(SHCEntity, SwitchEntity): diff --git a/homeassistant/components/bosch_shc/translations/nb.json b/homeassistant/components/bosch_shc/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/no.json b/homeassistant/components/bosch_shc/translations/no.json index 1b5b9fb0642987be9074bd86818c1885fa1c69ac..9d5ecc41b4cc05e4daf65e77a8fd3e09a31095c5 100644 --- a/homeassistant/components/bosch_shc/translations/no.json +++ b/homeassistant/components/bosch_shc/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py index d06482e5c713ce9c3e5c5c96d3f91ea2ec9d0b33..321d864f0366cc35729ea3d259d0c2b9d1d625de 100644 --- a/homeassistant/components/braviatv/__init__.py +++ b/homeassistant/components/braviatv/__init__.py @@ -7,11 +7,11 @@ from aiohttp import CookieJar from pybravia import BraviaTV from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN, Platform +from homeassistant.const import CONF_HOST, CONF_MAC, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import CONF_IGNORED_SOURCES, CONF_USE_PSK, DOMAIN +from .const import CONF_IGNORED_SOURCES, DOMAIN from .coordinator import BraviaTVCoordinator PLATFORMS: Final[list[Platform]] = [ @@ -25,8 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Set up a config entry.""" host = config_entry.data[CONF_HOST] mac = config_entry.data[CONF_MAC] - pin = config_entry.data[CONF_PIN] - use_psk = config_entry.data.get(CONF_USE_PSK, False) ignored_sources = config_entry.options.get(CONF_IGNORED_SOURCES, []) session = async_create_clientsession( @@ -36,8 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinator = BraviaTVCoordinator( hass=hass, client=client, - pin=pin, - use_psk=use_psk, + config=config_entry.data, ignored_sources=ignored_sources, ) config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index e6bf5a44019ad263c482781f535169607fd5e542..f5c7826b82547f14c0403159c0a1e0a116fe7597 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Bravia TV integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from urllib.parse import urlparse @@ -14,6 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.util.network import is_host_valid @@ -23,11 +25,12 @@ from .const import ( ATTR_CID, ATTR_MAC, ATTR_MODEL, - CLIENTID_PREFIX, + CONF_CLIENT_ID, CONF_IGNORED_SOURCES, + CONF_NICKNAME, CONF_USE_PSK, DOMAIN, - NICKNAME, + NICKNAME_PREFIX, ) @@ -40,6 +43,9 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Initialize config flow.""" self.client: BraviaTV | None = None self.device_config: dict[str, Any] = {} + self.entry: ConfigEntry | None = None + self.client_id: str = "" + self.nickname: str = "" @staticmethod @callback @@ -66,8 +72,10 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if use_psk: await self.client.connect(psk=pin) else: + self.device_config[CONF_CLIENT_ID] = self.client_id + self.device_config[CONF_NICKNAME] = self.nickname await self.client.connect( - pin=pin, clientid=CLIENTID_PREFIX, nickname=NICKNAME + pin=pin, clientid=self.client_id, nickname=self.nickname ) await self.client.set_wol_mode(True) @@ -108,6 +116,7 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Authorize Bravia TV device.""" errors: dict[str, str] = {} + self.client_id, self.nickname = await self.gen_instance_ids() if user_input is not None: self.device_config[CONF_PIN] = user_input[CONF_PIN] @@ -124,7 +133,7 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): assert self.client try: - await self.client.pair(CLIENTID_PREFIX, NICKNAME) + await self.client.pair(self.client_id, self.nickname) except BraviaTVError: return self.async_abort(reason="no_ip_control") @@ -177,6 +186,64 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="confirm") + 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"]) + self.device_config = {**entry_data} + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + self.create_client() + client_id, nickname = await self.gen_instance_ids() + + assert self.client is not None + assert self.entry is not None + + if user_input is not None: + pin = user_input[CONF_PIN] + use_psk = user_input[CONF_USE_PSK] + try: + if use_psk: + await self.client.connect(psk=pin) + else: + self.device_config[CONF_CLIENT_ID] = client_id + self.device_config[CONF_NICKNAME] = nickname + await self.client.connect( + pin=pin, clientid=client_id, nickname=nickname + ) + await self.client.set_wol_mode(True) + except BraviaTVError: + return self.async_abort(reason="reauth_unsuccessful") + else: + self.hass.config_entries.async_update_entry( + self.entry, data={**self.device_config, **user_input} + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + try: + await self.client.pair(client_id, nickname) + except BraviaTVError: + return self.async_abort(reason="reauth_unsuccessful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PIN, default=""): str, + vol.Required(CONF_USE_PSK, default=False): bool, + } + ), + ) + + async def gen_instance_ids(self) -> tuple[str, str]: + """Generate client_id and nickname.""" + uuid = await instance_id.async_get(self.hass) + return uuid, f"{NICKNAME_PREFIX} {uuid[:6]}" + class BraviaTVOptionsFlowHandler(config_entries.OptionsFlow): """Config flow options for Bravia TV.""" diff --git a/homeassistant/components/braviatv/const.py b/homeassistant/components/braviatv/const.py index 8855499914c3496931c29d98b0584b34992b915a..e7bdf00d5074edf6a1cc56f9e04a1dc45696985d 100644 --- a/homeassistant/components/braviatv/const.py +++ b/homeassistant/components/braviatv/const.py @@ -8,9 +8,11 @@ ATTR_MAC: Final = "macAddr" ATTR_MANUFACTURER: Final = "Sony" ATTR_MODEL: Final = "model" +CONF_CLIENT_ID: Final = "client_id" CONF_IGNORED_SOURCES: Final = "ignored_sources" +CONF_NICKNAME: Final = "nickname" CONF_USE_PSK: Final = "use_psk" -CLIENTID_PREFIX: Final = "HomeAssistant" DOMAIN: Final = "braviatv" -NICKNAME: Final = "Home Assistant" +LEGACY_CLIENT_ID: Final = "HomeAssistant" +NICKNAME_PREFIX: Final = "Home Assistant" diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index f81284838524d22b7f71e28844a3949d68301f8a..1262e7bf7cc0053c13738de41c88931ec89587de 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -5,10 +5,12 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable from datetime import timedelta from functools import wraps import logging +from types import MappingProxyType from typing import Any, Final, TypeVar from pybravia import ( BraviaTV, + BraviaTVAuthError, BraviaTVConnectionError, BraviaTVConnectionTimeout, BraviaTVError, @@ -18,11 +20,20 @@ from pybravia import ( from typing_extensions import Concatenate, ParamSpec from homeassistant.components.media_player import MediaType +from homeassistant.const import CONF_PIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CLIENTID_PREFIX, DOMAIN, NICKNAME +from .const import ( + CONF_CLIENT_ID, + CONF_NICKNAME, + CONF_USE_PSK, + DOMAIN, + LEGACY_CLIENT_ID, + NICKNAME_PREFIX, +) _BraviaTVCoordinatorT = TypeVar("_BraviaTVCoordinatorT", bound="BraviaTVCoordinator") _P = ParamSpec("_P") @@ -59,15 +70,16 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): self, hass: HomeAssistant, client: BraviaTV, - pin: str, - use_psk: bool, + config: MappingProxyType[str, Any], ignored_sources: list[str], ) -> None: """Initialize Bravia TV Client.""" self.client = client - self.pin = pin - self.use_psk = use_psk + self.pin = config[CONF_PIN] + self.use_psk = config.get(CONF_USE_PSK, False) + self.client_id = config.get(CONF_CLIENT_ID, LEGACY_CLIENT_ID) + self.nickname = config.get(CONF_NICKNAME, NICKNAME_PREFIX) self.ignored_sources = ignored_sources self.source: str | None = None self.source_list: list[str] = [] @@ -117,7 +129,7 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): await self.client.connect(psk=self.pin) else: await self.client.connect( - pin=self.pin, clientid=CLIENTID_PREFIX, nickname=NICKNAME + pin=self.pin, clientid=self.client_id, nickname=self.nickname ) self.connected = True @@ -139,6 +151,8 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): _LOGGER.debug("Update skipped, Bravia API service is reloading") return raise UpdateFailed("Error communicating with device") from err + except BraviaTVAuthError as err: + raise ConfigEntryAuthFailed from err except (BraviaTVConnectionError, BraviaTVConnectionTimeout, BraviaTVTurnedOff): self.is_on = False self.connected = False diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index ffb92a2348fc34310365ed36b3467fb1bae77a65..fa009bf05ef7da48ccce9980480aedaf7d4ef1b1 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -12,5 +12,6 @@ ], "config_flow": true, "iot_class": "local_polling", - "loggers": ["pybravia"] + "loggers": ["pybravia"], + "integration_type": "device" } diff --git a/homeassistant/components/braviatv/strings.json b/homeassistant/components/braviatv/strings.json index f6c35f2b8cafe84ac393a51f255c52303393b979..4dd08135896e9a4fe899b7422f23d388ffc3deb2 100644 --- a/homeassistant/components/braviatv/strings.json +++ b/homeassistant/components/braviatv/strings.json @@ -17,6 +17,13 @@ }, "confirm": { "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "reauth_confirm": { + "description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Deregister remote device. \n\nYou can use PSK (Pre-Shared-Key) instead of PIN. PSK is a user-defined secret key used for access control. This authentication method is recommended as more stable. To enable PSK on your TV, go to: Settings -> Network -> Home Network Setup -> IP Control. Then check «Use PSK authentication» box and enter your PSK instead of PIN.", + "data": { + "pin": "[%key:common::config_flow::data::pin%]", + "use_psk": "Use PSK authentication" + } } }, "error": { @@ -28,7 +35,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_ip_control": "IP Control is disabled on your TV or the TV is not supported.", - "not_bravia_device": "The device is not a Bravia TV." + "not_bravia_device": "The device is not a Bravia TV.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again." } }, "options": { diff --git a/homeassistant/components/braviatv/translations/bg.json b/homeassistant/components/braviatv/translations/bg.json index f43846e2283baf76b4a9c3ba421a483edfb75041..3a6908b01770cc33102b82e4ea56f120df9f4b4b 100644 --- a/homeassistant/components/braviatv/translations/bg.json +++ b/homeassistant/components/braviatv/translations/bg.json @@ -2,7 +2,9 @@ "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", - "not_bravia_device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043d\u0435 \u0435 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 Bravia." + "not_bravia_device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043d\u0435 \u0435 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 Bravia.", + "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", + "reauth_unsuccessful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u0442\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0431\u0435\u0448\u0435 \u043d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u0430, \u043c\u043e\u043b\u044f, \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0435\u0442\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0438 \u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e." }, "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", @@ -13,12 +15,19 @@ "step": { "authorize": { "data": { - "pin": "\u041f\u0418\u041d \u043a\u043e\u0434" + "pin": "\u041f\u0418\u041d \u043a\u043e\u0434", + "use_psk": "\u0418\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 PSK \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" } }, "confirm": { "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u0437\u0430\u043f\u043e\u0447\u043d\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0442\u0430?" }, + "reauth_confirm": { + "data": { + "pin": "\u041f\u0418\u041d \u043a\u043e\u0434", + "use_psk": "\u0418\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 PSK \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + } + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442" diff --git a/homeassistant/components/braviatv/translations/ca.json b/homeassistant/components/braviatv/translations/ca.json index 6c8c97367500d5ca60d94e2c2bbd0ef3307182d7..e6f8ebc8e9204c95e122f8dff53f5fbfe3fd8655 100644 --- a/homeassistant/components/braviatv/translations/ca.json +++ b/homeassistant/components/braviatv/translations/ca.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", "no_ip_control": "El control IP del teu televisor est\u00e0 desactivat o aquest no \u00e9s compatible.", - "not_bravia_device": "El dispositiu no \u00e9s un televisor Bravia." + "not_bravia_device": "El dispositiu no \u00e9s un televisor Bravia.", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", + "reauth_unsuccessful": "La re-autenticaci\u00f3 no ha tingut \u00e8xit, elimina la integraci\u00f3 i torna-la a configurar." }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", @@ -17,12 +19,19 @@ "pin": "Codi PIN", "use_psk": "Utilitza autenticaci\u00f3 PSK" }, - "description": "Introdueix el codi PIN que es mostra a la pantalla del televisor.\n\nSi no es mostra el codi, has d'eliminar Home Assistant del teu televisor. V\u00e9s a Configuraci\u00f3 > Xarxa > Configuraci\u00f3 de dispositiu remot > Elimina dispositiu remot.", + "description": "Introdueix el codi PIN que es mostra al televisor Sony Bravia.\n\nSi no es mostra el codi, has d'eliminar Home Assistant del teu televisor. V\u00e9s a: Configuraci\u00f3 -> Xarxa -> Configuraci\u00f3 de dispositiu remot -> Elimina dispositiu remot.\n\nPots utilitzar una clau PSK (Pre-Shared-Key) enlloc d'un codi PIN. La clau PSK est\u00e0 definida per l'usuari i s'utilitza per al control d'acc\u00e9s. Es recomana aquest m\u00e8tode d'autenticaci\u00f3, ja que \u00e9s m\u00e9s estable. Per activar la clau PSK, v\u00e9s a: Configuraci\u00f3 -> Xarxa -> Configuraci\u00f3 de xarxa local -> Control IP. Tot seguit, marca la casella \u00abUtilitza autenticaci\u00f3 PSK\u00bb i introdueix la clau que desitgis enlloc del PIN.", "title": "Autoritzaci\u00f3 del televisor Sony Bravia" }, "confirm": { "description": "Vols comen\u00e7ar la configuraci\u00f3?" }, + "reauth_confirm": { + "data": { + "pin": "Codi PIN", + "use_psk": "Utilitza autenticaci\u00f3 PSK" + }, + "description": "Introdueix el codi PIN que es mostra al televisor Sony Bravia.\n\nSi no es mostra el codi, has d'eliminar Home Assistant del teu televisor. V\u00e9s a: Configuraci\u00f3 -> Xarxa -> Configuraci\u00f3 de dispositiu remot -> Elimina dispositiu remot.\n\nPots utilitzar una clau PSK (Pre-Shared-Key) enlloc d'un codi PIN. La clau PSK est\u00e0 definida per l'usuari i s'utilitza per al control d'acc\u00e9s. Es recomana aquest m\u00e8tode d'autenticaci\u00f3, ja que \u00e9s m\u00e9s estable. Per activar la clau PSK, v\u00e9s a: Configuraci\u00f3 -> Xarxa -> Configuraci\u00f3 de xarxa local -> Control IP. Tot seguit, marca la casella \u00abUtilitza autenticaci\u00f3 PSK\u00bb i introdueix la clau que desitgis enlloc del PIN." + }, "user": { "data": { "host": "Amfitri\u00f3" diff --git a/homeassistant/components/braviatv/translations/cs.json b/homeassistant/components/braviatv/translations/cs.json index f08c5d828619e24cd09ad094b17bd5f065c5ce73..59eed7f418774fcbe2213d978c87317d62984419 100644 --- a/homeassistant/components/braviatv/translations/cs.json +++ b/homeassistant/components/braviatv/translations/cs.json @@ -16,6 +16,9 @@ "description": "Zadejte PIN k\u00f3d zobrazen\u00fd na televizi Sony Bravia.\n\nPokud se PIN k\u00f3d nezobraz\u00ed, je t\u0159eba zru\u0161it registraci Home Assistant na televizi, p\u0159ejd\u011bte na: Nastaven\u00ed -> S\u00ed\u0165 -> Nastaven\u00ed vzd\u00e1len\u00e9ho za\u0159\u00edzen\u00ed -> Zru\u0161it registraci vzd\u00e1len\u00e9ho za\u0159\u00edzen\u00ed.", "title": "Autorizujte televizi Sony Bravia" }, + "confirm": { + "description": "Chcete za\u010d\u00edt nastavovat?" + }, "user": { "data": { "host": "Hostitel" diff --git a/homeassistant/components/braviatv/translations/de.json b/homeassistant/components/braviatv/translations/de.json index 18863481bc09aac9acacfbe156e6afe50ab3d66a..f62d496f2d3b8339cde4d6b27a3faaaeb8993258 100644 --- a/homeassistant/components/braviatv/translations/de.json +++ b/homeassistant/components/braviatv/translations/de.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", "no_ip_control": "IP-Steuerung ist auf deinen Fernseher deaktiviert oder der Fernseher wird nicht unterst\u00fctzt.", - "not_bravia_device": "Das Ger\u00e4t ist kein Bravia-Fernseher." + "not_bravia_device": "Das Ger\u00e4t ist kein Bravia-Fernseher.", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", + "reauth_unsuccessful": "Die erneute Authentifizierung war nicht erfolgreich. Bitte entferne die Integration und richte sie erneut ein." }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -23,6 +25,13 @@ "confirm": { "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" }, + "reauth_confirm": { + "data": { + "pin": "PIN-Code", + "use_psk": "PSK-Authentifizierung verwenden" + }, + "description": "Gib den auf dem Sony Bravia-Fernseher angezeigten PIN-Code ein. \n\nWenn der PIN-Code nicht angezeigt wird, musst du die Registrierung von Home Assistant auf Ihrem Fernseher aufheben, gehe zu: Einstellungen - > Netzwerk - > Remote-Ger\u00e4teeinstellungen - > Remote-Ger\u00e4t abmelden. \n\nDu kannst PSK (Pre-Shared-Key) anstelle der PIN verwenden. PSK ist ein benutzerdefinierter geheimer Schl\u00fcssel, der f\u00fcr die Zugriffskontrolle verwendet wird. Diese Authentifizierungsmethode wird als stabiler empfohlen. Um PSK auf deinem Fernseher zu aktivieren, gehe zu: Einstellungen - > Netzwerk - > Heimnetzwerk-Setup - > IP-Steuerung. Aktiviere dann das Kontrollk\u00e4stchen \u00abPSK-Authentifizierung verwenden\u00bb und gib deinen PSK anstelle der PIN ein." + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/components/braviatv/translations/el.json b/homeassistant/components/braviatv/translations/el.json index 070f546d532083170db9c3f974bd3aba75e615c4..fc3ba88c57e063362d1859efe23352476558a20c 100644 --- a/homeassistant/components/braviatv/translations/el.json +++ b/homeassistant/components/braviatv/translations/el.json @@ -2,21 +2,36 @@ "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", - "no_ip_control": "\u039f \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 IP \u03b5\u03af\u03bd\u03b1\u03b9 \u03b1\u03c0\u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03bf\u03c2 \u03c3\u03c4\u03b7\u03bd \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03ae \u03c3\u03b1\u03c2 \u03ae \u03b7 \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03b7 \u03b4\u03b5\u03bd \u03c5\u03c0\u03bf\u03c3\u03c4\u03b7\u03c1\u03af\u03b6\u03b5\u03c4\u03b1\u03b9." + "no_ip_control": "\u039f \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 IP \u03b5\u03af\u03bd\u03b1\u03b9 \u03b1\u03c0\u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03bf\u03c2 \u03c3\u03c4\u03b7\u03bd \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03ae \u03c3\u03b1\u03c2 \u03ae \u03b7 \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03b7 \u03b4\u03b5\u03bd \u03c5\u03c0\u03bf\u03c3\u03c4\u03b7\u03c1\u03af\u03b6\u03b5\u03c4\u03b1\u03b9.", + "not_bravia_device": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03b7 Bravia.", + "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", + "reauth_unsuccessful": "\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 \u03b1\u03bd\u03b5\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2. \u039a\u03b1\u03c4\u03b1\u03c1\u03b3\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 \u03ba\u03b1\u03b9 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03be\u03b1\u03bd\u03ac." }, "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", "invalid_host": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf \u03cc\u03bd\u03bf\u03bc\u03b1 \u03ba\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03bf\u03cd \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae \u03ae \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 IP", "unsupported_model": "\u03a4\u03bf \u03bc\u03bf\u03bd\u03c4\u03ad\u03bb\u03bf \u03c4\u03b7\u03c2 \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03ae\u03c2 \u03c3\u03b1\u03c2 \u03b4\u03b5\u03bd \u03c5\u03c0\u03bf\u03c3\u03c4\u03b7\u03c1\u03af\u03b6\u03b5\u03c4\u03b1\u03b9." }, "step": { "authorize": { "data": { - "pin": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 PIN" + "pin": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 PIN", + "use_psk": "\u03a7\u03c1\u03ae\u03c3\u03b7 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 PSK" }, "description": "\u0395\u03b9\u03c3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf\u03bd \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc PIN \u03c0\u03bf\u03c5 \u03b5\u03bc\u03c6\u03b1\u03bd\u03af\u03b6\u03b5\u03c4\u03b1\u03b9 \u03c3\u03c4\u03b7\u03bd \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03b7 Sony Bravia. \n\n\u0395\u03ac\u03bd \u03bf \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 PIN \u03b4\u03b5\u03bd \u03b5\u03bc\u03c6\u03b1\u03bd\u03af\u03b6\u03b5\u03c4\u03b1\u03b9, \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03ae\u03c3\u03b5\u03c4\u03b5 \u03c4\u03b7\u03bd \u03b5\u03b3\u03b3\u03c1\u03b1\u03c6\u03ae \u03c4\u03bf\u03c5 Home Assistant \u03c3\u03c4\u03b7\u03bd \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03ae \u03c3\u03b1\u03c2, \u03bc\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7: \u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03b9\u03c2 -> \u0394\u03af\u03ba\u03c4\u03c5\u03bf -> \u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03b9\u03c2 \u03b1\u03c0\u03bf\u03bc\u03b1\u03ba\u03c1\u03c5\u03c3\u03bc\u03ad\u03bd\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2 -> \u0394\u03b9\u03b1\u03b3\u03c1\u03b1\u03c6\u03ae \u03c4\u03b7\u03c2 \u03ba\u03b1\u03c4\u03b1\u03c7\u03ce\u03c1\u03b9\u03c3\u03b7\u03c2 \u03b1\u03c0\u03bf\u03bc\u03b1\u03ba\u03c1\u03c5\u03c3\u03bc\u03ad\u03bd\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2.", "title": "\u0395\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03cc\u03c4\u03b7\u03c3\u03b7 Sony Bravia TV" }, + "confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03be\u03b5\u03ba\u03b9\u03bd\u03ae\u03c3\u03b5\u03c4\u03b5 \u03c4\u03b7 \u03c1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7;" + }, + "reauth_confirm": { + "data": { + "pin": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 PIN", + "use_psk": "\u03a7\u03c1\u03ae\u03c3\u03b7 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 PSK" + }, + "description": "\u0395\u03b9\u03c3\u03b1\u03b3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf\u03bd \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc PIN \u03c0\u03bf\u03c5 \u03b5\u03bc\u03c6\u03b1\u03bd\u03af\u03b6\u03b5\u03c4\u03b1\u03b9 \u03c3\u03c4\u03b7\u03bd \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03b7 Sony Bravia. \n\n \u0395\u03ac\u03bd \u03b4\u03b5\u03bd \u03b5\u03bc\u03c6\u03b1\u03bd\u03af\u03b6\u03b5\u03c4\u03b1\u03b9 \u03bf \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 PIN, \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03ae\u03c3\u03b5\u03c4\u03b5 \u03c4\u03b7\u03bd \u03b5\u03b3\u03b3\u03c1\u03b1\u03c6\u03ae \u03c4\u03bf\u03c5 Home Assistant \u03c3\u03c4\u03b7\u03bd \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03ae \u03c3\u03b1\u03c2, \u03bc\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03bf: \u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03b9\u03c2 - > \u0394\u03af\u03ba\u03c4\u03c5\u03bf - > \u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03b9\u03c2 \u03b1\u03c0\u03bf\u03bc\u03b1\u03ba\u03c1\u03c5\u03c3\u03bc\u03ad\u03bd\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2 - > \u039a\u03b1\u03c4\u03ac\u03c1\u03b3\u03b7\u03c3\u03b7 \u03b5\u03b3\u03b3\u03c1\u03b1\u03c6\u03ae\u03c2 \u03b1\u03c0\u03bf\u03bc\u03b1\u03ba\u03c1\u03c5\u03c3\u03bc\u03ad\u03bd\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2. \n\n \u039c\u03c0\u03bf\u03c1\u03b5\u03af\u03c4\u03b5 \u03bd\u03b1 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03ae\u03c3\u03b5\u03c4\u03b5 PSK (Pre-Shared-Key) \u03b1\u03bd\u03c4\u03af \u03b3\u03b9\u03b1 PIN. \u03a4\u03bf PSK \u03b5\u03af\u03bd\u03b1\u03b9 \u03ad\u03bd\u03b1 \u03bc\u03c5\u03c3\u03c4\u03b9\u03ba\u03cc \u03ba\u03bb\u03b5\u03b9\u03b4\u03af \u03c0\u03bf\u03c5 \u03bf\u03c1\u03af\u03b6\u03b5\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03bf \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7 \u03ba\u03b1\u03b9 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9 \u03b3\u03b9\u03b1 \u03c4\u03bf\u03bd \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2. \u0391\u03c5\u03c4\u03ae \u03b7 \u03bc\u03ad\u03b8\u03bf\u03b4\u03bf\u03c2 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03c3\u03c5\u03bd\u03b9\u03c3\u03c4\u03ac\u03c4\u03b1\u03b9 \u03c9\u03c2 \u03c0\u03b9\u03bf \u03c3\u03c4\u03b1\u03b8\u03b5\u03c1\u03ae. \u0393\u03b9\u03b1 \u03bd\u03b1 \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03ae\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf PSK \u03c3\u03c4\u03b7\u03bd \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03ae \u03c3\u03b1\u03c2, \u03bc\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b9\u03c2 \u03b5\u03be\u03ae\u03c2 \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03ad\u03c2: \u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03b9\u03c2 - > \u0394\u03af\u03ba\u03c4\u03c5\u03bf - > \u03a1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7 \u03bf\u03b9\u03ba\u03b9\u03b1\u03ba\u03bf\u03cd \u03b4\u03b9\u03ba\u03c4\u03cd\u03bf\u03c5 - > \u0388\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 IP. \u03a3\u03c4\u03b7 \u03c3\u03c5\u03bd\u03ad\u03c7\u03b5\u03b9\u03b1, \u03b5\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 \u03c4\u03bf \u03c0\u03bb\u03b1\u03af\u03c3\u03b9\u03bf \u00ab\u03a7\u03c1\u03ae\u03c3\u03b7 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 PSK\u00bb \u03ba\u03b1\u03b9 \u03c0\u03bb\u03b7\u03ba\u03c4\u03c1\u03bf\u03bb\u03bf\u03b3\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf PSK \u03c3\u03b1\u03c2 \u03b1\u03bd\u03c4\u03af \u03b3\u03b9\u03b1 \u03c4\u03bf PIN." + }, "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/braviatv/translations/en.json b/homeassistant/components/braviatv/translations/en.json index 7401fda732445671a97811b735769f2825fde547..c3341d331128059a720eba5cc4b3ff092e683569 100644 --- a/homeassistant/components/braviatv/translations/en.json +++ b/homeassistant/components/braviatv/translations/en.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "Device is already configured", "no_ip_control": "IP Control is disabled on your TV or the TV is not supported.", - "not_bravia_device": "The device is not a Bravia TV." + "not_bravia_device": "The device is not a Bravia TV.", + "reauth_successful": "Re-authentication was successful", + "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again." }, "error": { "cannot_connect": "Failed to connect", @@ -23,6 +25,13 @@ "confirm": { "description": "Do you want to start set up?" }, + "reauth_confirm": { + "data": { + "pin": "PIN Code", + "use_psk": "Use PSK authentication" + }, + "description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Deregister remote device. \n\nYou can use PSK (Pre-Shared-Key) instead of PIN. PSK is a user-defined secret key used for access control. This authentication method is recommended as more stable. To enable PSK on your TV, go to: Settings -> Network -> Home Network Setup -> IP Control. Then check \u00abUse PSK authentication\u00bb box and enter your PSK instead of PIN." + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/components/braviatv/translations/es.json b/homeassistant/components/braviatv/translations/es.json index 325ccb4c535c29380cc816f44dfe3d19eeb019ea..fbcd69a0bec81f874bf0c435b76d1e6f89cb6e17 100644 --- a/homeassistant/components/braviatv/translations/es.json +++ b/homeassistant/components/braviatv/translations/es.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "El dispositivo ya est\u00e1 configurado", "no_ip_control": "El Control IP est\u00e1 desactivado en tu TV o la TV no es compatible.", - "not_bravia_device": "El dispositivo no es una TV Bravia." + "not_bravia_device": "El dispositivo no es una TV Bravia.", + "reauth_successful": "La autenticaci\u00f3n se volvi\u00f3 a realizar correctamente", + "reauth_unsuccessful": "No se pudo volver a autenticar, por favor, elimina la integraci\u00f3n y vuelve a configurarla." }, "error": { "cannot_connect": "No se pudo conectar", @@ -23,6 +25,13 @@ "confirm": { "description": "\u00bfQuieres iniciar la configuraci\u00f3n?" }, + "reauth_confirm": { + "data": { + "pin": "C\u00f3digo PIN", + "use_psk": "Usar autenticaci\u00f3n PSK" + }, + "description": "Introduce el c\u00f3digo PIN que se muestra en la TV Sony Bravia. \n\nSi no se muestra el c\u00f3digo PIN, debes cancelar el registro de Home Assistant en tu TV, ve a: Configuraci\u00f3n -> Red -> Configuraci\u00f3n del dispositivo remoto -> Cancelar el registro del dispositivo remoto. \n\nPuedes usar PSK (clave precompartida) en lugar de PIN. PSK es una clave secreta definida por el usuario que se utiliza para el control de acceso. Este m\u00e9todo de autenticaci\u00f3n se recomienda como m\u00e1s estable. Para habilitar PSK en tu TV, ve a: Configuraci\u00f3n -> Red -> Configuraci\u00f3n de red dom\u00e9stica -> Control de IP. Luego marca la casilla \u00abUsar autenticaci\u00f3n PSK\u00bb e introduce tu PSK en lugar de PIN." + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/components/braviatv/translations/et.json b/homeassistant/components/braviatv/translations/et.json index ecfc89b968de1171f4b50844c55a11fed96817af..4e3ca6333d4e1e23e2683e9af2b22685a5eaf7e7 100644 --- a/homeassistant/components/braviatv/translations/et.json +++ b/homeassistant/components/braviatv/translations/et.json @@ -2,21 +2,36 @@ "config": { "abort": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", - "no_ip_control": "Teleris on IP-juhtimine keelatud v\u00f5i telerit ei toetata." + "no_ip_control": "Teleris on IP-juhtimine keelatud v\u00f5i telerit ei toetata.", + "not_bravia_device": "Seade ei ole Bravia teler.", + "reauth_successful": "Taastuvastamine \u00f5nnestus", + "reauth_unsuccessful": "Taasautentimine eba\u00f5nnestus, eemalda sidumine ja seadista see uuesti." }, "error": { "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", "invalid_host": "Vigane hostinimi v\u00f5i IP aadress", "unsupported_model": "Teleri mudelit ei toetata." }, "step": { "authorize": { "data": { - "pin": "PIN kood" + "pin": "PIN kood", + "use_psk": "PSK autentimise kasutamine" }, - "description": "Sisesta Sony Bravia teleris kuvatud PIN-kood.\n\n Kui PIN-koodi ei kuvata pead teleri Home Assistan'i sidumise t\u00fchistama. Mine: Seaded - > V\u00f5rk - > Kaugseadme seaded - > Kaugseadme registreerimise t\u00fchistamine.", + "description": "Sisestage Sony Bravia teleril n\u00e4idatud PIN-kood. \n\nKui PIN-koodi ei kuvata, peate teleril Home Assistant'i registreerimise t\u00fchistama, minge aadressile: Seaded -> Network -> Remote device settings -> Deregister remote device. \n\nPIN-koodi asemel v\u00f5ite kasutada PSK (Pre-Shared-Key). PSK on kasutaja m\u00e4\u00e4ratud salajane v\u00f5ti, mida kasutatakse juurdep\u00e4\u00e4su kontrollimiseks. See autentimismeetod on soovitatav kui stabiilsem. PSK lubamiseks teleril minge aadressil: Settings -> Network -> Home Network Setup -> IP Control. Seej\u00e4rel m\u00e4rgistage ruut \"Kasutage PSK autentimist\" ja sisestage PIN-koodi asemel PSK.", "title": "Sony Bravia TV autoriseerimine" }, + "confirm": { + "description": "Kas alustada seadistamist?" + }, + "reauth_confirm": { + "data": { + "pin": "PIN kood", + "use_psk": "PSK autentimise kasutamine" + }, + "description": "Sisesta Sony Bravia teleril n\u00e4idatud PIN-kood. \n\nKui PIN-koodi ei kuvata, peadeleril Home Assistant'i registreerimise t\u00fchistama, mine aadressile: Seaded -> Network -> Remote device settings -> Deregister remote device. \n\nPIN-koodi asemel v\u00f5id kasutada PSK (Pre-Shared-Key). PSK on kasutaja m\u00e4\u00e4ratud salajane v\u00f5ti, mida kasutatakse juurdep\u00e4\u00e4su kontrollimiseks. See autentimismeetod on soovitatav kui stabiilsem. PSK lubamiseks teleril mine aadressil: Settings -> Network -> Home Network Setup -> IP Control. Seej\u00e4rel m\u00e4rgista ruut \"Kasutage PSK autentimist\" ja sisesta PIN-koodi asemel PSK." + }, "user": { "data": { "host": "" diff --git a/homeassistant/components/braviatv/translations/fr.json b/homeassistant/components/braviatv/translations/fr.json index 73a32d0a06ac39278262e44d0d1537757acebe61..40445ec806273fa5200106f51ab55d426a330ee6 100644 --- a/homeassistant/components/braviatv/translations/fr.json +++ b/homeassistant/components/braviatv/translations/fr.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "no_ip_control": "Le contr\u00f4le IP est d\u00e9sactiv\u00e9 sur votre t\u00e9l\u00e9viseur ou le t\u00e9l\u00e9viseur n'est pas pris en charge.", - "not_bravia_device": "L'appareil n'est pas un t\u00e9l\u00e9viseur Bravia." + "not_bravia_device": "L'appareil n'est pas un t\u00e9l\u00e9viseur Bravia.", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", + "reauth_unsuccessful": "La r\u00e9authentification a \u00e9chou\u00e9, veuillez supprimer l'int\u00e9gration puis la configurer \u00e0 nouveau." }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -23,6 +25,12 @@ "confirm": { "description": "Voulez-vous commencer la configuration\u00a0?" }, + "reauth_confirm": { + "data": { + "pin": "Code PIN", + "use_psk": "Utiliser l'authentification PSK" + } + }, "user": { "data": { "host": "H\u00f4te" diff --git a/homeassistant/components/braviatv/translations/he.json b/homeassistant/components/braviatv/translations/he.json index b717638aa2fde87025100ecd531e2d97302f4698..29c90cda7694a1247577cbbd1744c843477cb699 100644 --- a/homeassistant/components/braviatv/translations/he.json +++ b/homeassistant/components/braviatv/translations/he.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", @@ -17,6 +18,11 @@ "confirm": { "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" }, + "reauth_confirm": { + "data": { + "pin": "\u05e7\u05d5\u05d3 PIN" + } + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7" diff --git a/homeassistant/components/braviatv/translations/hu.json b/homeassistant/components/braviatv/translations/hu.json index 02b64050ee2b6b2d7ee3b818ad822da6593779c3..0912003f74ff494499085d643e50170693f1430c 100644 --- a/homeassistant/components/braviatv/translations/hu.json +++ b/homeassistant/components/braviatv/translations/hu.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "no_ip_control": "Az IP-vez\u00e9rl\u00e9s le van tiltva a TV-n, vagy a TV nem t\u00e1mogatja.", - "not_bravia_device": "A k\u00e9sz\u00fcl\u00e9k nem egy Bravia TV." + "not_bravia_device": "A k\u00e9sz\u00fcl\u00e9k nem egy Bravia TV.", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt.", + "reauth_unsuccessful": "Az \u00fajrahiteles\u00edt\u00e9s sikertelen volt, k\u00e9rem, t\u00e1vol\u00edtsa el az integr\u00e1ci\u00f3t, \u00e9s \u00e1ll\u00edtsa be \u00fajra." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -23,6 +25,13 @@ "confirm": { "description": "El szeretn\u00e9 kezdeni a be\u00e1ll\u00edt\u00e1st?" }, + "reauth_confirm": { + "data": { + "pin": "PIN-k\u00f3d", + "use_psk": "PSK hiteles\u00edt\u00e9s haszn\u00e1lata" + }, + "description": "\u00cdrja be a Sony Bravia TV -n l\u00e1that\u00f3 PIN -k\u00f3dot. \n\nHa a PIN -k\u00f3d nem jelenik meg, t\u00f6r\u00f6lje a Home Assistant regisztr\u00e1ci\u00f3j\u00e1t a t\u00e9v\u00e9n, az al\u00e1bbiak szerint: Be\u00e1ll\u00edt\u00e1sok - > H\u00e1l\u00f3zat - > T\u00e1voli eszk\u00f6z be\u00e1ll\u00edt\u00e1sai - > T\u00e1vol\u00edtsa el a t\u00e1voli eszk\u00f6z regisztr\u00e1ci\u00f3j\u00e1t.\n\nA PIN-k\u00f3d helyett haszn\u00e1lhat PSK-t (Pre-Shared-Key). A PSK egy felhaszn\u00e1l\u00f3 \u00e1ltal meghat\u00e1rozott titkos kulcs, amelyet a hozz\u00e1f\u00e9r\u00e9s ellen\u0151rz\u00e9s\u00e9re haszn\u00e1lnak. Ez a hiteles\u00edt\u00e9si m\u00f3dszer aj\u00e1nlott, mivel stabilabb. A PSK enged\u00e9lyez\u00e9s\u00e9hez a TV-n, l\u00e9pjen a k\u00f6vetkez\u0151 oldalra: Be\u00e1ll\u00edt\u00e1sok -> H\u00e1l\u00f3zat -> Otthoni h\u00e1l\u00f3zat be\u00e1ll\u00edt\u00e1sa -> IP-vez\u00e9rl\u00e9s. Ezut\u00e1n jel\u00f6lje be a \"PSK hiteles\u00edt\u00e9s haszn\u00e1lata\" jel\u00f6l\u0151n\u00e9gyzetet, \u00e9s adja meg a PSK-t a PIN-k\u00f3d helyett." + }, "user": { "data": { "host": "C\u00edm" diff --git a/homeassistant/components/braviatv/translations/id.json b/homeassistant/components/braviatv/translations/id.json index 63b4353aefc824db5a715be8c3fc957f25121545..853dd7da29edb76f45aeb69b4a66343adbf1bb16 100644 --- a/homeassistant/components/braviatv/translations/id.json +++ b/homeassistant/components/braviatv/translations/id.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "Perangkat sudah dikonfigurasi", "no_ip_control": "Kontrol IP dinonaktifkan di TV Anda atau TV tidak didukung.", - "not_bravia_device": "Perangkat ini bukan TV Bravia." + "not_bravia_device": "Perangkat ini bukan TV Bravia.", + "reauth_successful": "Autentikasi ulang berhasil", + "reauth_unsuccessful": "Autentikasi ulang tidak berhasil, hapus integrasi dan siapkan kembali." }, "error": { "cannot_connect": "Gagal terhubung", @@ -23,6 +25,13 @@ "confirm": { "description": "Ingin memulai penyiapan?" }, + "reauth_confirm": { + "data": { + "pin": "Kode PIN", + "use_psk": "Gunakan autentikasi PSK" + }, + "description": "Masukkan kode PIN yang ditampilkan di TV Sony Bravia. \n\nJika kode PIN tidak ditampilkan, Anda harus membatalkan pendaftaran Home Assistant di TV, buka: Pengaturan -> Jaringan -> Pengaturan perangkat jarak jauh -> Batalkan pendaftaran perangkat jarak jauh.\n\nAnda bisa menggunakan PSK (Pre-Shared-Key) alih-alih menggunakan PIN. PSK merupakan kunci rahasia yang ditentukan pengguna untuk mengakses kontrol. Metode autentikasi ini disarankan karena lebih stabil. Untuk mengaktifkan PSK di TV Anda, buka Pengaturan -> Jaringan -> Penyiapan Jaringan Rumah -> Kontrol IP, lalu centang \u00abGunakan autentikasi PSK\u00bb dan masukkan PSK Anda, bukan PIN." + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/components/braviatv/translations/it.json b/homeassistant/components/braviatv/translations/it.json index 8ac0b2d7df9f6ac8072d90ab41f73cafddc71d94..7bf9bb98b5a0abe17ec18530674855a41b85d3a4 100644 --- a/homeassistant/components/braviatv/translations/it.json +++ b/homeassistant/components/braviatv/translations/it.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "no_ip_control": "Il controllo IP \u00e8 disabilitato sulla TV o la TV non \u00e8 supportata.", - "not_bravia_device": "Il dispositivo non \u00e8 una TV Bravia." + "not_bravia_device": "Il dispositivo non \u00e8 una TV Bravia.", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", + "reauth_unsuccessful": "La nuova autenticazione non ha avuto esito positivo, rimuovere l'integrazione e configurarla di nuovo." }, "error": { "cannot_connect": "Impossibile connettersi", @@ -23,6 +25,13 @@ "confirm": { "description": "Vuoi iniziare la configurazione?" }, + "reauth_confirm": { + "data": { + "pin": "Codice PIN", + "use_psk": "Usa l'autenticazione PSK" + }, + "description": "Inserisci il codice PIN mostrato sul Sony Bravia TV. \n\nSe il codice PIN non viene visualizzato, devi annullare la registrazione di Home Assistant sulla TV, vai su: Impostazioni -> Rete -> Impostazioni dispositivo remoto -> Annulla registrazione dispositivo remoto. \n\nPuoi usare PSK (Pre-Shared-Key) invece del PIN. PSK \u00e8 una chiave segreta definita dall'utente utilizzata per il controllo degli accessi. Questo metodo di autenticazione \u00e8 consigliato poich\u00e9 pi\u00f9 stabile. Per abilitare PSK sulla tua TV, vai su: Impostazioni -> Rete -> Configurazione rete domestica -> Controllo IP. Quindi seleziona la casella \u00abUtilizza l'autenticazione PSK\u00bb e inserisci la tua chiave PSK anzich\u00e9 il PIN." + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/components/braviatv/translations/ja.json b/homeassistant/components/braviatv/translations/ja.json index 263de9e35b0da1d5c29188091ae25e730b649e9e..3b541f3a42444cae7c66202a89293f1338eab38f 100644 --- a/homeassistant/components/braviatv/translations/ja.json +++ b/homeassistant/components/braviatv/translations/ja.json @@ -2,21 +2,33 @@ "config": { "abort": { "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", - "no_ip_control": "\u30c6\u30ec\u30d3\u3067IP\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u304c\u7121\u52b9\u306b\u306a\u3063\u3066\u3044\u308b\u304b\u3001\u30c6\u30ec\u30d3\u304c\u5bfe\u5fdc\u3057\u3066\u3044\u307e\u305b\u3093\u3002" + "no_ip_control": "\u30c6\u30ec\u30d3\u3067IP\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u304c\u7121\u52b9\u306b\u306a\u3063\u3066\u3044\u308b\u304b\u3001\u30c6\u30ec\u30d3\u304c\u5bfe\u5fdc\u3057\u3066\u3044\u307e\u305b\u3093\u3002", + "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", "invalid_host": "\u7121\u52b9\u306a\u30db\u30b9\u30c8\u540d\u307e\u305f\u306fIP\u30a2\u30c9\u30ec\u30b9", "unsupported_model": "\u304a\u4f7f\u3044\u306e\u30c6\u30ec\u30d3\u306e\u30e2\u30c7\u30eb\u306f\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002" }, "step": { "authorize": { "data": { - "pin": "PIN\u30b3\u30fc\u30c9" + "pin": "PIN\u30b3\u30fc\u30c9", + "use_psk": "PSK\u8a8d\u8a3c\u3092\u4f7f\u7528\u3059\u308b" }, "description": "\u30bd\u30cb\u30fc Bravia TV\u306b\u8868\u793a\u3055\u308c\u3066\u3044\u308bPIN\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u307e\u3059\u3002 \n\nPIN\u30b3\u30fc\u30c9\u304c\u8868\u793a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u3001\u30c6\u30ec\u30d3\u304b\u3089Home Assistant\u306e\u767b\u9332\u3092\u89e3\u9664\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u306e\u3067\u3001\u6b21\u306e\u624b\u9806\u3067\u884c\u3063\u3066\u304f\u3060\u3055\u3044\u3002\u8a2d\u5b9a \u2192 \u30cd\u30c3\u30c8\u30ef\u30fc\u30af \u2192 \u30ea\u30e2\u30fc\u30c8\u30c7\u30d0\u30a4\u30b9\u306e\u8a2d\u5b9a \u2192 \u30ea\u30e2\u30fc\u30c8\u30c7\u30d0\u30a4\u30b9\u306e\u767b\u9332\u89e3\u9664 \u3092\u884c\u3063\u3066\u304f\u3060\u3055\u3044\u3002", "title": "\u30bd\u30cb\u30fc Bravia TV\u3092\u8a8d\u8a3c\u3059\u308b" }, + "confirm": { + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u958b\u59cb\u3057\u307e\u3059\u304b\uff1f" + }, + "reauth_confirm": { + "data": { + "pin": "PIN\u30b3\u30fc\u30c9", + "use_psk": "PSK\u8a8d\u8a3c\u3092\u4f7f\u7528\u3059\u308b" + } + }, "user": { "data": { "host": "\u30db\u30b9\u30c8" diff --git a/homeassistant/components/braviatv/translations/nb.json b/homeassistant/components/braviatv/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..e9911d9e5733a20a639a7a466915dea99597d37a --- /dev/null +++ b/homeassistant/components/braviatv/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "invalid_auth": "Ugyldig autentisering" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/nl.json b/homeassistant/components/braviatv/translations/nl.json index 18a4f8881fbcb08a814b05243a434e8d00b36665..31dc484416378be1f2d57c667347fb04f3e73778 100644 --- a/homeassistant/components/braviatv/translations/nl.json +++ b/homeassistant/components/braviatv/translations/nl.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "Apparaat is al geconfigureerd", "no_ip_control": "IP-besturing is uitgeschakeld op uw tv of de tv wordt niet ondersteund.", - "not_bravia_device": "Dit apparaat is geen Bravia-TV." + "not_bravia_device": "Dit apparaat is geen Bravia-TV.", + "reauth_successful": "Herauthenticatie geslaagd" }, "error": { "cannot_connect": "Kan geen verbinding maken", @@ -23,6 +24,12 @@ "confirm": { "description": "Wil je beginnen met instellen?" }, + "reauth_confirm": { + "data": { + "pin": "Pincode", + "use_psk": "PSK-authenticatie gebruiken" + } + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/components/braviatv/translations/no.json b/homeassistant/components/braviatv/translations/no.json index 7fa16b90e8ac4e42c311c06d128c42a4049a36e9..dec2157d6a35afc2643a4700a558c99728f56a57 100644 --- a/homeassistant/components/braviatv/translations/no.json +++ b/homeassistant/components/braviatv/translations/no.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "Enheten er allerede konfigurert", "no_ip_control": "IP-kontrollen er deaktivert p\u00e5 TVen eller TV-en st\u00f8ttes ikke.", - "not_bravia_device": "Enheten er ikke en Bravia TV." + "not_bravia_device": "Enheten er ikke en Bravia TV.", + "reauth_successful": "Re-autentisering var vellykket", + "reauth_unsuccessful": "Re-autentisering mislyktes. Fjern integrasjonen og konfigurer den p\u00e5 nytt." }, "error": { "cannot_connect": "Tilkobling mislyktes", @@ -23,6 +25,13 @@ "confirm": { "description": "Vil du starte oppsettet?" }, + "reauth_confirm": { + "data": { + "pin": "PIN kode", + "use_psk": "Bruk PSK-autentisering" + }, + "description": "Skriv inn PIN-koden som vises p\u00e5 Sony Bravia TV. \n\n Hvis PIN-koden ikke vises, m\u00e5 du avregistrere Home Assistant p\u00e5 TV-en din, g\u00e5 til: Innstillinger - > Nettverk - > Innstillinger for ekstern enhet - > Avregistrer ekstern enhet. \n\n Du kan bruke PSK (Pre-Shared-Key) i stedet for PIN. PSK er en brukerdefinert hemmelig n\u00f8kkel som brukes til tilgangskontroll. Denne autentiseringsmetoden anbefales som mer stabil. For \u00e5 aktivere PSK p\u00e5 TV-en, g\u00e5 til: Innstillinger - > Nettverk - > Oppsett for hjemmenettverk - > IP-kontroll. Kryss s\u00e5 av \u00abBruk PSK-autentisering\u00bb-boksen og skriv inn din PSK i stedet for PIN-kode." + }, "user": { "data": { "host": "Vert" diff --git a/homeassistant/components/braviatv/translations/pl.json b/homeassistant/components/braviatv/translations/pl.json index 83521554e218e790041ac80bf486d93e784fbc8b..adc3a67e603ee83c0d79c977d214bc65eade9b4f 100644 --- a/homeassistant/components/braviatv/translations/pl.json +++ b/homeassistant/components/braviatv/translations/pl.json @@ -2,21 +2,36 @@ "config": { "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", - "no_ip_control": "Sterowanie IP jest wy\u0142\u0105czone w telewizorze lub telewizor nie jest obs\u0142ugiwany" + "no_ip_control": "Sterowanie IP jest wy\u0142\u0105czone w telewizorze lub telewizor nie jest obs\u0142ugiwany", + "not_bravia_device": "Urz\u0105dzenie nie jest telewizorem Bravia", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", + "reauth_unsuccessful": "B\u0142\u0105d ponownego uwierzytelnienia, usu\u0144 integracj\u0119 i skonfiguruj j\u0105 ponownie" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", "invalid_host": "Nieprawid\u0142owa nazwa hosta lub adres IP", "unsupported_model": "Ten model telewizora nie jest obs\u0142ugiwany" }, "step": { "authorize": { "data": { - "pin": "Kod PIN" + "pin": "Kod PIN", + "use_psk": "U\u017cyj uwierzytelniania PSK" }, - "description": "Wprowad\u017a kod PIN wy\u015bwietlany na telewizorze Sony Bravia. \n\nJe\u015bli kod PIN nie jest wy\u015bwietlany, musisz wyrejestrowa\u0107 Home Assistanta na swoim telewizorze, przejd\u017a do Ustawienia -> Sie\u0107 -> Ustawienia urz\u0105dzenia zdalnego -> Wyrejestruj urz\u0105dzenie zdalne.", + "description": "Wprowad\u017a kod PIN wy\u015bwietlany na telewizorze Sony Bravia. \n\nJe\u015bli kod PIN nie jest wy\u015bwietlany, musisz wyrejestrowa\u0107 Home Assistanta na telewizorze. Przejd\u017a do: Ustawienia - > Sie\u0107 - > Ustawienia urz\u0105dzenia zdalnego - > Wyrejestruj zdalne urz\u0105dzenie. \n\nMo\u017cesz u\u017cy\u0107 PSK (Pre-Shared-Key) zamiast kodu PIN. PSK to zdefiniowany przez u\u017cytkownika tajny klucz u\u017cywany do kontroli dost\u0119pu. Ta metoda uwierzytelniania jest zalecana jako bardziej stabilna. Aby w\u0142\u0105czy\u0107 PSK na telewizorze, przejd\u017a do: Ustawienia - > Sie\u0107 - > Konfiguracja sieci domowej - > Sterowanie IP. Nast\u0119pnie zaznacz pole \u201eU\u017cyj uwierzytelniania PSK\u201d i wprowad\u017a sw\u00f3j PSK zamiast kodu PIN.", "title": "Autoryzacja Sony Bravia TV" }, + "confirm": { + "description": "Czy chcesz rozpocz\u0105\u0107 konfiguracj\u0119?" + }, + "reauth_confirm": { + "data": { + "pin": "Kod PIN", + "use_psk": "U\u017cyj uwierzytelniania PSK" + }, + "description": "Wprowad\u017a kod PIN wy\u015bwietlany na telewizorze Sony Bravia. \n\nJe\u015bli kod PIN nie jest wy\u015bwietlany, musisz wyrejestrowa\u0107 Home Assistanta na telewizorze. Przejd\u017a do: Ustawienia - > Sie\u0107 - > Ustawienia urz\u0105dzenia zdalnego - > Wyrejestruj zdalne urz\u0105dzenie. \n\nMo\u017cesz u\u017cy\u0107 PSK (Pre-Shared-Key) zamiast kodu PIN. PSK to zdefiniowany przez u\u017cytkownika tajny klucz u\u017cywany do kontroli dost\u0119pu. Ta metoda uwierzytelniania jest zalecana jako bardziej stabilna. Aby w\u0142\u0105czy\u0107 PSK na telewizorze, przejd\u017a do: Ustawienia - > Sie\u0107 - > Konfiguracja sieci domowej - > Sterowanie IP. Nast\u0119pnie zaznacz pole \u201eU\u017cyj uwierzytelniania PSK\u201d i wprowad\u017a sw\u00f3j PSK zamiast kodu PIN." + }, "user": { "data": { "host": "Nazwa hosta lub adres IP" diff --git a/homeassistant/components/braviatv/translations/pt-BR.json b/homeassistant/components/braviatv/translations/pt-BR.json index a6aef38d17f39e59143c436172516a41297599bd..7c5af6e269470fdaa14258584d569fc4b7363f04 100644 --- a/homeassistant/components/braviatv/translations/pt-BR.json +++ b/homeassistant/components/braviatv/translations/pt-BR.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", "no_ip_control": "O Controle de IP est\u00e1 desativado em sua TV ou a TV n\u00e3o \u00e9 compat\u00edvel.", - "not_bravia_device": "O dispositivo n\u00e3o \u00e9 uma TV Bravia." + "not_bravia_device": "O dispositivo n\u00e3o \u00e9 uma TV Bravia.", + "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida", + "reauth_unsuccessful": "A reautentica\u00e7\u00e3o falhou. Remova a integra\u00e7\u00e3o e configure-a novamente." }, "error": { "cannot_connect": "Falha ao conectar", @@ -23,6 +25,13 @@ "confirm": { "description": "Deseja iniciar a configura\u00e7\u00e3o?" }, + "reauth_confirm": { + "data": { + "pin": "C\u00f3digo PIN", + "use_psk": "Usar autentica\u00e7\u00e3o PSK" + }, + "description": "Digite o c\u00f3digo PIN mostrado na TV Sony Bravia. \n\n Se o c\u00f3digo PIN n\u00e3o for exibido, voc\u00ea deve cancelar o registro do Home Assistant na sua TV, v\u00e1 para: Configura\u00e7\u00f5es - > Rede - > Configura\u00e7\u00f5es do dispositivo remoto - > Cancelar o registro do dispositivo remoto. \n\n Voc\u00ea pode usar PSK (Pre-Shared-Key) em vez de PIN. PSK \u00e9 uma chave secreta definida pelo usu\u00e1rio usada para controle de acesso. Este m\u00e9todo de autentica\u00e7\u00e3o \u00e9 recomendado como mais est\u00e1vel. Para ativar o PSK em sua TV, v\u00e1 para: Configura\u00e7\u00f5es - > Rede - > Configura\u00e7\u00e3o de rede dom\u00e9stica - > Controle de IP. Em seguida, marque a caixa \u00abUsar autentica\u00e7\u00e3o PSK\u00bb e digite seu PSK em vez do PIN." + }, "user": { "data": { "host": "Nome do host" diff --git a/homeassistant/components/braviatv/translations/ru.json b/homeassistant/components/braviatv/translations/ru.json index f191e3607cc1075565362de1d7db05dc732557d2..8416d9e5ede067c874955ea9bd3efa5945682c1d 100644 --- a/homeassistant/components/braviatv/translations/ru.json +++ b/homeassistant/components/braviatv/translations/ru.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", "no_ip_control": "\u041d\u0430 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0435 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u043f\u043e IP, \u043b\u0438\u0431\u043e \u044d\u0442\u0430 \u043c\u043e\u0434\u0435\u043b\u044c \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.", - "not_bravia_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 Bravia." + "not_bravia_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 Bravia.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e.", + "reauth_unsuccessful": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u0443\u044e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e. \u0423\u0434\u0430\u043b\u0438\u0442\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044e \u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0435\u0451 \u0441\u043d\u043e\u0432\u0430." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", @@ -23,6 +25,13 @@ "confirm": { "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443?" }, + "reauth_confirm": { + "data": { + "pin": "PIN-\u043a\u043e\u0434", + "use_psk": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c PSK-\u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 PIN-\u043a\u043e\u0434, \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u044e\u0449\u0438\u0439\u0441\u044f \u043d\u0430 \u044d\u043a\u0440\u0430\u043d\u0435 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430 Sony Bravia. \n\n\u0415\u0441\u043b\u0438 \u0412\u044b \u043d\u0435 \u0432\u0438\u0434\u0438\u0442\u0435 PIN-\u043a\u043e\u0434, \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044e Home Assistant \u043d\u0430 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0435. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u0432 \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 -> \u0421\u0435\u0442\u044c -> \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0434\u0430\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 -> \u041e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044e \u0443\u0434\u0430\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.\n\n\u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c PSK (Pre-Shared-Key) \u0432\u043c\u0435\u0441\u0442\u043e PIN-\u043a\u043e\u0434\u0430. PSK \u2014 \u044d\u0442\u043e \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u044f\u0435\u043c\u044b\u0439 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u043c \u0441\u0435\u043a\u0440\u0435\u0442\u043d\u044b\u0439 \u043a\u043b\u044e\u0447, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0439 \u0434\u043b\u044f \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f \u0434\u043e\u0441\u0442\u0443\u043f\u043e\u043c. \u042d\u0442\u043e\u0442 \u043c\u0435\u0442\u043e\u0434 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u0442\u0441\u044f \u043a\u0430\u043a \u0431\u043e\u043b\u0435\u0435 \u0441\u0442\u0430\u0431\u0438\u043b\u044c\u043d\u044b\u0439. \u0427\u0442\u043e\u0431\u044b \u0432\u043a\u043b\u044e\u0447\u0438\u0442\u044c PSK \u043d\u0430 \u0412\u0430\u0448\u0435\u043c \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0435, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u0432 \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 - > \u0421\u0435\u0442\u044c - > \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043e\u043c\u0430\u0448\u043d\u0435\u0439 \u0441\u0435\u0442\u0438 - > \u0423\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 IP. \u0417\u0430\u0442\u0435\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0435 \u0444\u043b\u0430\u0436\u043e\u043a \u00ab\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e PSK\u00bb \u0438 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0441\u0432\u043e\u0439 PSK \u0432\u043c\u0435\u0441\u0442\u043e PIN-\u043a\u043e\u0434\u0430." + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442" diff --git a/homeassistant/components/braviatv/translations/sv.json b/homeassistant/components/braviatv/translations/sv.json index f47ee5c3a77989271bd556394c5bc455149d90d9..9b218f562d2bb31f7cd80bbd49e96c8155889a0c 100644 --- a/homeassistant/components/braviatv/translations/sv.json +++ b/homeassistant/components/braviatv/translations/sv.json @@ -2,21 +2,36 @@ "config": { "abort": { "already_configured": "Den h\u00e4r TV:n \u00e4r redan konfigurerad", - "no_ip_control": "IP-kontroll \u00e4r inaktiverat p\u00e5 din TV eller s\u00e5 st\u00f6ds inte TV:n." + "no_ip_control": "IP-kontroll \u00e4r inaktiverat p\u00e5 din TV eller s\u00e5 st\u00f6ds inte TV:n.", + "not_bravia_device": "Enheten \u00e4r inte en Bravia TV.", + "reauth_successful": "\u00c5terautentisering lyckades", + "reauth_unsuccessful": "\u00c5terautentiseringen misslyckades. Ta bort integrationen och konfigurera den igen." }, "error": { "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering", "invalid_host": "Ogiltigt v\u00e4rdnamn eller IP-adress.", "unsupported_model": "Den h\u00e4r tv modellen st\u00f6ds inte." }, "step": { "authorize": { "data": { - "pin": "Pin-kod" + "pin": "Pin-kod", + "use_psk": "Anv\u00e4nd PSK-autentisering" }, "description": "Ange PIN-koden som visas p\u00e5 Sony Bravia TV. \n\n Om PIN-koden inte visas m\u00e5ste du avregistrera Home Assistant p\u00e5 din TV, g\u00e5 till: Inst\u00e4llningar - > N\u00e4tverk - > Inst\u00e4llningar f\u00f6r fj\u00e4rrenhet - > Avregistrera fj\u00e4rrenhet.", "title": "Auktorisera Sony Bravia TV" }, + "confirm": { + "description": "Vill du starta konfigurationen?" + }, + "reauth_confirm": { + "data": { + "pin": "Pin-kod", + "use_psk": "Anv\u00e4nd PSK-autentisering" + }, + "description": "Ange PIN-koden som visas p\u00e5 Sony Bravia TV. \n\n Om PIN-koden inte visas m\u00e5ste du avregistrera Home Assistant p\u00e5 din TV, g\u00e5 till: Inst\u00e4llningar - > N\u00e4tverk - > Inst\u00e4llningar f\u00f6r fj\u00e4rrenhet - > Avregistrera fj\u00e4rrenhet. \n\n Du kan anv\u00e4nda PSK (Pre-Shared-Key) ist\u00e4llet f\u00f6r PIN. PSK \u00e4r en anv\u00e4ndardefinierad hemlig nyckel som anv\u00e4nds f\u00f6r \u00e5tkomstkontroll. Denna autentiseringsmetod rekommenderas eftersom den \u00e4r mer stabil. F\u00f6r att aktivera PSK p\u00e5 din TV, g\u00e5 till: Inst\u00e4llningar - > N\u00e4tverk - > Hemn\u00e4tverksinst\u00e4llningar - > IP-kontroll. Markera sedan rutan \u00abAnv\u00e4nd PSK-autentisering\u00bb och ange din PSK ist\u00e4llet f\u00f6r PIN-kod." + }, "user": { "data": { "host": "V\u00e4rdnamn eller IP-adress f\u00f6r TV" diff --git a/homeassistant/components/braviatv/translations/tr.json b/homeassistant/components/braviatv/translations/tr.json index cf5cc45640e47e72ec5edfb5cb3b00ac3d8249c2..939f8e71b7b88a5031f3e71ecc5fefc307b19c37 100644 --- a/homeassistant/components/braviatv/translations/tr.json +++ b/homeassistant/components/braviatv/translations/tr.json @@ -2,21 +2,36 @@ "config": { "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "no_ip_control": "TV'nizde IP Kontrol\u00fc devre d\u0131\u015f\u0131 veya TV desteklenmiyor." + "no_ip_control": "TV'nizde IP Kontrol\u00fc devre d\u0131\u015f\u0131 veya TV desteklenmiyor.", + "not_bravia_device": "Cihaz bir Bravia TV de\u011fildir.", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "reauth_unsuccessful": "Yeniden kimlik do\u011frulama ba\u015far\u0131s\u0131z oldu, l\u00fctfen entegrasyonu kald\u0131r\u0131n ve yeniden kurun." }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "invalid_host": "Ge\u00e7ersiz ana bilgisayar ad\u0131 veya IP adresi", "unsupported_model": "TV modeliniz desteklenmiyor." }, "step": { "authorize": { "data": { - "pin": "PIN Kodu" + "pin": "PIN Kodu", + "use_psk": "PSK kimlik do\u011frulamas\u0131n\u0131 kullan\u0131n" }, - "description": "Sony Bravia TV'de g\u00f6sterilen PIN kodunu girin. \n\n PIN kodu g\u00f6r\u00fcnt\u00fclenmiyorsa, TV'nizde Home Assistant kayd\u0131n\u0131 iptal etmeniz gerekir, \u015furaya gidin: Ayarlar - > A\u011f - > Uzak cihaz ayarlar\u0131 - > Uzak cihaz\u0131n kayd\u0131n\u0131 iptal et.Home Assistant", + "description": "Sony Bravia TV'de g\u00f6sterilen PIN kodunu girin. \n\n PIN kodu g\u00f6r\u00fcnt\u00fclenmezse, TV'nizde Home Assistant kayd\u0131n\u0131 iptal etmeniz gerekir, \u015furaya gidin: Ayarlar - > A\u011f - > Uzak cihaz ayarlar\u0131 - > Uzak cihaz\u0131n kayd\u0131n\u0131 sil. \n\n PIN yerine PSK (\u00d6n Payla\u015f\u0131ml\u0131 Anahtar) kullanabilirsiniz. PSK, eri\u015fim kontrol\u00fc i\u00e7in kullan\u0131lan kullan\u0131c\u0131 tan\u0131ml\u0131 bir gizli anahtard\u0131r. Bu kimlik do\u011frulama y\u00f6nteminin daha kararl\u0131 olmas\u0131 \u00f6nerilir. TV'nizde PSK'y\u0131 etkinle\u015ftirmek i\u00e7in \u015furaya gidin: Ayarlar - > A\u011f - > Ev A\u011f\u0131 Kurulumu - > IP Kontrol\u00fc. Ard\u0131ndan \u00abPSK kimlik do\u011frulamas\u0131n\u0131 kullan\u00bb kutusunu i\u015faretleyin ve PIN yerine PSK'n\u0131z\u0131 girin.", "title": "Sony Bravia TV'yi yetkilendirin" }, + "confirm": { + "description": "Kuruluma ba\u015flamak ister misiniz?" + }, + "reauth_confirm": { + "data": { + "pin": "PIN Kodu", + "use_psk": "PSK kimlik do\u011frulamas\u0131n\u0131 kullan\u0131n" + }, + "description": "Sony Bravia TV'de g\u00f6sterilen PIN kodunu girin. \n\n PIN kodu g\u00f6r\u00fcnt\u00fclenmezse, TV'nizde Home Assistant kayd\u0131n\u0131 iptal etmeniz gerekir, \u015furaya gidin: Ayarlar - > A\u011f - > Uzak cihaz ayarlar\u0131 - > Uzak cihaz\u0131n kayd\u0131n\u0131 sil. \n\n PIN yerine PSK (\u00d6n Payla\u015f\u0131ml\u0131 Anahtar) kullanabilirsiniz. PSK, eri\u015fim kontrol\u00fc i\u00e7in kullan\u0131lan kullan\u0131c\u0131 tan\u0131ml\u0131 bir gizli anahtard\u0131r. Bu kimlik do\u011frulama y\u00f6nteminin daha kararl\u0131 olmas\u0131 \u00f6nerilir. TV'nizde PSK'y\u0131 etkinle\u015ftirmek i\u00e7in \u015furaya gidin: Ayarlar - > A\u011f - > Ev A\u011f\u0131 Kurulumu - > IP Kontrol\u00fc. Ard\u0131ndan \u00abPSK kimlik do\u011frulamas\u0131n\u0131 kullan\u00bb kutusunu i\u015faretleyin ve PIN yerine PSK'n\u0131z\u0131 girin." + }, "user": { "data": { "host": "Ana Bilgisayar" diff --git a/homeassistant/components/braviatv/translations/zh-Hans.json b/homeassistant/components/braviatv/translations/zh-Hans.json index 447c136dcf4ea6fe923758e40142560f5b5c01d3..6f115e243ace2ac0417a7d1edb18177dbc7ff545 100644 --- a/homeassistant/components/braviatv/translations/zh-Hans.json +++ b/homeassistant/components/braviatv/translations/zh-Hans.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "reauth_successful": "\u91cd\u65b0\u8ba4\u8bc1\u6210\u529f", + "reauth_unsuccessful": "\u91cd\u65b0\u9a8c\u8bc1\u5931\u8d25\uff0c\u8bf7\u79fb\u9664\u96c6\u6210\u5e76\u91cd\u65b0\u8bbe\u7f6e\u3002" + }, "step": { "authorize": { "data": { @@ -8,6 +12,13 @@ "description": "\u8f93\u5165\u5728 Sony Bravia \u7535\u89c6\u4e0a\u663e\u793a\u7684 PIN \u7801\u3002 \n\n\u5982\u679c\u672a\u663e\u793a PIN \u7801\uff0c\u60a8\u9700\u8981\u5728\u7535\u89c6\u4e0a\u53d6\u6d88\u6ce8\u518c Home Assistant\uff0c\u8bf7\u8f6c\u5230\uff1a\u8bbe\u7f6e - >\u7f51\u7edc - >\u8fdc\u7a0b\u8bbe\u5907\u8bbe\u7f6e - >\u53d6\u6d88\u6ce8\u518c\u8fdc\u7a0b\u8bbe\u5907\u3002", "title": "\u6388\u6743 Sony Bravia \u7535\u89c6" }, + "reauth_confirm": { + "data": { + "pin": "PIN\u7801", + "use_psk": "\u4f7f\u7528 PSK \u8ba4\u8bc1" + }, + "description": "\u8f93\u5165 Sony Bravia \u7535\u89c6\u4e0a\u663e\u793a\u7684 PIN \u7801\u3002 \n\n\u5982\u679c PIN \u7801\u672a\u663e\u793a\uff0c\u60a8\u5fc5\u987b\u5728\u7535\u89c6\u4e0a\u53d6\u6d88\u6ce8\u518c Home Assistant\uff0c\u524d\u5f80\uff1a\u8bbe\u7f6e - >\u7f51\u7edc - >\u8fdc\u7a0b\u8bbe\u5907\u8bbe\u7f6e - >\u53d6\u6d88\u6ce8\u518c\u8fdc\u7a0b\u8bbe\u5907\u3002 \n\n\u60a8\u53ef\u4ee5\u4f7f\u7528 PSK\uff08\u9884\u5171\u4eab\u5bc6\u94a5\uff09\u4ee3\u66ff PIN\u3002 PSK \u662f\u7528\u4e8e\u8bbf\u95ee\u63a7\u5236\u7684\u7528\u6237\u5b9a\u4e49\u7684\u5bc6\u94a5\u3002\u63a8\u8350\u4f7f\u7528\u8fd9\u79cd\u8eab\u4efd\u9a8c\u8bc1\u65b9\u6cd5\uff0c\u56e0\u4e3a\u5b83\u66f4\u7a33\u5b9a\u3002\u8981\u5728\u7535\u89c6\u4e0a\u542f\u7528 PSK\uff0c\u8bf7\u8f6c\u5230\uff1a\u8bbe\u7f6e - >\u7f51\u7edc - >\u5bb6\u5ead\u7f51\u7edc\u8bbe\u7f6e - > IP \u63a7\u5236\u3002\u7136\u540e\u9009\u4e2d\u00ab\u4f7f\u7528 PSK \u8eab\u4efd\u9a8c\u8bc1\u00bb\u6846\u5e76\u8f93\u5165\u60a8\u7684 PSK \u800c\u4e0d\u662f PIN\u3002" + }, "user": { "description": "\u8bbe\u7f6e Sony Bravia \u7535\u89c6\u96c6\u6210\u3002\u5982\u679c\u60a8\u5728\u914d\u7f6e\u65b9\u9762\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u8bbf\u95ee\uff1ahttps://www.home-assistant.io/integrations/braviatv\n\u786e\u4fdd\u7535\u89c6\u5df2\u6253\u5f00\u3002" } diff --git a/homeassistant/components/braviatv/translations/zh-Hant.json b/homeassistant/components/braviatv/translations/zh-Hant.json index 9753715eec1af81ebb4733ace1d90fd2944bae04..e30142c947b57d2ef8c9c3d2801317dc66c0ee0b 100644 --- a/homeassistant/components/braviatv/translations/zh-Hant.json +++ b/homeassistant/components/braviatv/translations/zh-Hant.json @@ -3,7 +3,9 @@ "abort": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "no_ip_control": "\u96fb\u8996\u4e0a\u7684 IP \u5df2\u95dc\u9589\u6216\u4e0d\u652f\u63f4\u6b64\u6b3e\u96fb\u8996\u3002", - "not_bravia_device": "\u88dd\u7f6e\u4e26\u975e Bravia TV\u3002" + "not_bravia_device": "\u88dd\u7f6e\u4e26\u975e Bravia TV\u3002", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", + "reauth_unsuccessful": "\u91cd\u65b0\u9a57\u8b49\u5931\u6557\uff0c\u8acb\u79fb\u9664\u88dd\u7f6e\u4e26\u91cd\u65b0\u8a2d\u5b9a\u3002" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", @@ -23,6 +25,13 @@ "confirm": { "description": "\u662f\u5426\u8981\u958b\u59cb\u8a2d\u5b9a\uff1f" }, + "reauth_confirm": { + "data": { + "pin": "PIN \u78bc", + "use_psk": "\u4f7f\u7528 PSK \u9a57\u8b49" + }, + "description": "\u8f38\u5165 Sony Bravia \u96fb\u8996\u6240\u986f\u793a\u4e4b PIN \u78bc\u3002\n\n\u5047\u5982 PIN \u78bc\u672a\u986f\u793a\uff0c\u5fc5\u9808\u5148\u65bc\u96fb\u8996\u89e3\u9664 Home Assistant \u8a3b\u518a\uff0c\u6b65\u9a5f\u70ba\uff1a\u8a2d\u5b9a -> \u7db2\u8def -> \u9060\u7aef\u88dd\u7f6e\u8a2d\u5b9a -> \u89e3\u9664\u9060\u7aef\u88dd\u7f6e\u8a3b\u518a\u3002\n\n\u53ef\u4f7f\u7528 PSK (Pre-Shared-Key) \u53d6\u4ee3 PIN \u78bc\u3002PSK \u70ba\u4f7f\u7528\u8005\u81ea\u5b9a\u5bc6\u9470\u7528\u4ee5\u5b58\u53d6\u63a7\u5236\u3002\u5efa\u8b70\u63a1\u7528\u6b64\u8a8d\u8b49\u65b9\u5f0f\u66f4\u70ba\u7a69\u5b9a\u3002\u6b32\u65bc\u96fb\u8996\u555f\u7528 PSK\u3002\u6b65\u9a5f\u70ba\uff1a\u8a2d\u5b9a -> \u7db2\u8def -> \u5bb6\u5ead\u7db2\u8def\u8a2d\u5b9a -> IP \u63a7\u5236\u3002\u7136\u5f8c\u52fe\u9078 \u00ab\u4f7f\u7528 PSK \u8a8d\u8b49\u00bb \u4e26\u8f38\u5165 PSK \u78bc\u3002" + }, "user": { "data": { "host": "\u4e3b\u6a5f\u7aef" diff --git a/homeassistant/components/brel_home/manifest.json b/homeassistant/components/brel_home/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..02e06705de80cbb10bbb435def14f0bc84a60ec3 --- /dev/null +++ b/homeassistant/components/brel_home/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "brel_home", + "name": "Brel Home", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/broadlink/light.py b/homeassistant/components/broadlink/light.py index c4dd48b7dc04aeec30b3b720970bdc3b8a79fab2..d42e2b76b994a4d6595f023bb17059314a0bbdfd 100644 --- a/homeassistant/components/broadlink/light.py +++ b/homeassistant/components/broadlink/light.py @@ -44,10 +44,11 @@ async def async_setup_entry( class BroadlinkLight(BroadlinkEntity, LightEntity): """Representation of a Broadlink light.""" + _attr_has_entity_name = True + def __init__(self, device): """Initialize the light.""" super().__init__(device) - self._attr_name = f"{device.name} Light" self._attr_unique_id = device.unique_id self._attr_supported_color_modes = set() diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index da72f4fcb06a73bd750cdccbd6c97fb21a33d2e3..4bbb3fe151369805381cf9f8d3a7710d66784c50 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -106,6 +106,8 @@ async def async_setup_entry( class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): """Representation of a Broadlink remote.""" + _attr_has_entity_name = True + def __init__(self, device, codes, flags): """Initialize the remote.""" super().__init__(device) @@ -116,7 +118,6 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): self._flags = defaultdict(int) self._lock = asyncio.Lock() - self._attr_name = f"{device.name} Remote" self._attr_is_on = True self._attr_supported_features = ( RemoteEntityFeature.LEARN_COMMAND | RemoteEntityFeature.DELETE_COMMAND diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index f908391893f23a3beea0daeed7038569b9c6a0fe..4d362482e64c4a221177a4ffd31aac65da99fc60 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -32,7 +32,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="air_quality", - name="Air Quality", + name="Air quality", ), SensorEntityDescription( key="humidity", @@ -113,12 +113,13 @@ async def async_setup_entry( class BroadlinkSensor(BroadlinkEntity, SensorEntity): """Representation of a Broadlink sensor.""" + _attr_has_entity_name = True + def __init__(self, device, description: SensorEntityDescription): """Initialize the sensor.""" super().__init__(device) self.entity_description = description - self._attr_name = f"{device.name} {description.name}" self._attr_native_value = self._coordinator.data[description.key] self._attr_unique_id = f"{device.unique_id}-{description.key}" diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 229949b7ee2c7827774f63820319c9bd2bcbf63a..009536a9adb1b5e2acc90ebe49efde750745f612 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -145,7 +145,6 @@ class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC): super().__init__(device) self._command_on = command_on self._command_off = command_off - self._attr_name = f"{device.name} Switch" async def async_added_to_hass(self) -> None: """Call when the switch is added to hass.""" @@ -198,6 +197,8 @@ class BroadlinkRMSwitch(BroadlinkSwitch): class BroadlinkSP1Switch(BroadlinkSwitch): """Representation of a Broadlink SP1 switch.""" + _attr_has_entity_name = True + def __init__(self, device): """Initialize the switch.""" super().__init__(device, 1, 0) @@ -219,6 +220,7 @@ class BroadlinkSP2Switch(BroadlinkSP1Switch): """Representation of a Broadlink SP2 switch.""" _attr_assumed_state = False + _attr_has_entity_name = True def __init__(self, device, *args, **kwargs): """Initialize the switch.""" @@ -234,13 +236,14 @@ class BroadlinkMP1Slot(BroadlinkSwitch): """Representation of a Broadlink MP1 slot.""" _attr_assumed_state = False + _attr_has_entity_name = True def __init__(self, device, slot): """Initialize the switch.""" super().__init__(device, 1, 0) self._slot = slot self._attr_is_on = self._coordinator.data[f"s{slot}"] - self._attr_name = f"{device.name} S{slot}" + self._attr_name = f"S{slot}" self._attr_unique_id = f"{device.unique_id}-s{slot}" def _update_state(self, data): @@ -263,6 +266,7 @@ class BroadlinkBG1Slot(BroadlinkSwitch): """Representation of a Broadlink BG1 slot.""" _attr_assumed_state = False + _attr_has_entity_name = True def __init__(self, device, slot): """Initialize the switch.""" @@ -270,7 +274,7 @@ class BroadlinkBG1Slot(BroadlinkSwitch): self._slot = slot self._attr_is_on = self._coordinator.data[f"pwr{slot}"] - self._attr_name = f"{device.name} S{slot}" + self._attr_name = f"S{slot}" self._attr_device_class = SwitchDeviceClass.OUTLET self._attr_unique_id = f"{device.unique_id}-s{slot}" diff --git a/homeassistant/components/broadlink/translations/nb.json b/homeassistant/components/broadlink/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/broadlink/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 61b1d8bcdc9bd459f969e9aefcfc2d9123ebcea9..68922ecaeb385d35e43136e460f189259d242e70 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -13,5 +13,6 @@ "config_flow": true, "quality_scale": "platinum", "iot_class": "local_polling", - "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"] + "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], + "integration_type": "device" } diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 86f1d2d40ec20016e108a88e90b7907ce52e32fb..adb17d4283d50acac81b7fb184843e6b0eae708d 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -1,10 +1,12 @@ """Support for the Brother service.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime import logging -from typing import Any, cast + +from brother import BrotherSensors from homeassistant.components.sensor import ( DOMAIN as PLATFORM, @@ -15,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, PERCENTAGE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,356 +27,397 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import BrotherDataUpdateCoordinator from .const import DATA_CONFIG_ENTRY, DOMAIN -ATTR_BELT_UNIT_REMAINING_LIFE = "belt_unit_remaining_life" -ATTR_BLACK_DRUM_COUNTER = "black_drum_counter" -ATTR_BLACK_DRUM_REMAINING_LIFE = "black_drum_remaining_life" -ATTR_BLACK_DRUM_REMAINING_PAGES = "black_drum_remaining_pages" -ATTR_BLACK_INK_REMAINING = "black_ink_remaining" -ATTR_BLACK_TONER_REMAINING = "black_toner_remaining" -ATTR_BW_COUNTER = "bw_counter" -ATTR_COLOR_COUNTER = "color_counter" ATTR_COUNTER = "counter" -ATTR_CYAN_DRUM_COUNTER = "cyan_drum_counter" -ATTR_CYAN_DRUM_REMAINING_LIFE = "cyan_drum_remaining_life" -ATTR_CYAN_DRUM_REMAINING_PAGES = "cyan_drum_remaining_pages" -ATTR_CYAN_INK_REMAINING = "cyan_ink_remaining" -ATTR_CYAN_TONER_REMAINING = "cyan_toner_remaining" -ATTR_DRUM_COUNTER = "drum_counter" -ATTR_DRUM_REMAINING_LIFE = "drum_remaining_life" -ATTR_DRUM_REMAINING_PAGES = "drum_remaining_pages" -ATTR_DUPLEX_COUNTER = "duplex_unit_pages_counter" -ATTR_FUSER_REMAINING_LIFE = "fuser_remaining_life" -ATTR_LASER_REMAINING_LIFE = "laser_remaining_life" -ATTR_MAGENTA_DRUM_COUNTER = "magenta_drum_counter" -ATTR_MAGENTA_DRUM_REMAINING_LIFE = "magenta_drum_remaining_life" -ATTR_MAGENTA_DRUM_REMAINING_PAGES = "magenta_drum_remaining_pages" -ATTR_MAGENTA_INK_REMAINING = "magenta_ink_remaining" -ATTR_MAGENTA_TONER_REMAINING = "magenta_toner_remaining" -ATTR_MANUFACTURER = "Brother" -ATTR_PAGE_COUNTER = "page_counter" -ATTR_PF_KIT_1_REMAINING_LIFE = "pf_kit_1_remaining_life" -ATTR_PF_KIT_MP_REMAINING_LIFE = "pf_kit_mp_remaining_life" ATTR_REMAINING_PAGES = "remaining_pages" -ATTR_STATUS = "status" -ATTR_UPTIME = "uptime" -ATTR_YELLOW_DRUM_COUNTER = "yellow_drum_counter" -ATTR_YELLOW_DRUM_REMAINING_LIFE = "yellow_drum_remaining_life" -ATTR_YELLOW_DRUM_REMAINING_PAGES = "yellow_drum_remaining_pages" -ATTR_YELLOW_INK_REMAINING = "yellow_ink_remaining" -ATTR_YELLOW_TONER_REMAINING = "yellow_toner_remaining" UNIT_PAGES = "p" -ATTRS_MAP: dict[str, tuple[str, str]] = { - ATTR_DRUM_REMAINING_LIFE: (ATTR_DRUM_REMAINING_PAGES, ATTR_DRUM_COUNTER), - ATTR_BLACK_DRUM_REMAINING_LIFE: ( - ATTR_BLACK_DRUM_REMAINING_PAGES, - ATTR_BLACK_DRUM_COUNTER, - ), - ATTR_CYAN_DRUM_REMAINING_LIFE: ( - ATTR_CYAN_DRUM_REMAINING_PAGES, - ATTR_CYAN_DRUM_COUNTER, - ), - ATTR_MAGENTA_DRUM_REMAINING_LIFE: ( - ATTR_MAGENTA_DRUM_REMAINING_PAGES, - ATTR_MAGENTA_DRUM_COUNTER, - ), - ATTR_YELLOW_DRUM_REMAINING_LIFE: ( - ATTR_YELLOW_DRUM_REMAINING_PAGES, - ATTR_YELLOW_DRUM_COUNTER, - ), -} - _LOGGER = logging.getLogger(__name__) -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Add Brother entities from a config_entry.""" - coordinator = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] - - # Due to the change of the attribute name of one sensor, it is necessary to migrate - # the unique_id to the new one. - entity_registry = er.async_get(hass) - old_unique_id = f"{coordinator.data.serial.lower()}_b/w_counter" - if entity_id := entity_registry.async_get_entity_id( - PLATFORM, DOMAIN, old_unique_id - ): - new_unique_id = f"{coordinator.data.serial.lower()}_bw_counter" - _LOGGER.debug( - "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", - entity_id, - old_unique_id, - new_unique_id, - ) - entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) - - sensors = [] - - device_info = DeviceInfo( - configuration_url=f"http://{entry.data[CONF_HOST]}/", - identifiers={(DOMAIN, coordinator.data.serial)}, - manufacturer=ATTR_MANUFACTURER, - model=coordinator.data.model, - name=coordinator.data.model, - sw_version=coordinator.data.firmware, - ) - - for description in SENSOR_TYPES: - if getattr(coordinator.data, description.key) is not None: - sensors.append( - description.entity_class(coordinator, description, device_info) - ) - async_add_entities(sensors, False) - - -class BrotherPrinterSensor(CoordinatorEntity, SensorEntity): - """Define an Brother Printer sensor.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: BrotherDataUpdateCoordinator, - description: BrotherSensorEntityDescription, - device_info: DeviceInfo, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attrs: dict[str, Any] = {} - self._attr_device_info = device_info - self._attr_unique_id = f"{coordinator.data.serial.lower()}_{description.key}" - self.entity_description = description - - @property - def native_value(self) -> StateType | datetime: - """Return the state.""" - return cast( - StateType, getattr(self.coordinator.data, self.entity_description.key) - ) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - remaining_pages, drum_counter = ATTRS_MAP.get( - self.entity_description.key, (None, None) - ) - if remaining_pages and drum_counter: - self._attrs[ATTR_REMAINING_PAGES] = getattr( - self.coordinator.data, remaining_pages - ) - self._attrs[ATTR_COUNTER] = getattr(self.coordinator.data, drum_counter) - return self._attrs - - -class BrotherPrinterUptimeSensor(BrotherPrinterSensor): - """Define an Brother Printer Uptime sensor.""" +@dataclass +class BrotherSensorRequiredKeysMixin: + """Class for Brother entity required keys.""" - @property - def native_value(self) -> datetime: - """Return the state.""" - return cast( - datetime, getattr(self.coordinator.data, self.entity_description.key) - ) + value: Callable[[BrotherSensors], StateType | datetime] @dataclass -class BrotherSensorEntityDescription(SensorEntityDescription): +class BrotherSensorEntityDescription( + SensorEntityDescription, BrotherSensorRequiredKeysMixin +): """A class that describes sensor entities.""" - entity_class: type[BrotherPrinterSensor] = BrotherPrinterSensor - SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( - key=ATTR_STATUS, + key="status", icon="mdi:printer", name="Status", entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.status, ), BrotherSensorEntityDescription( - key=ATTR_PAGE_COUNTER, + key="page_counter", icon="mdi:file-document-outline", name="Page counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.page_counter, ), BrotherSensorEntityDescription( - key=ATTR_BW_COUNTER, + key="bw_counter", icon="mdi:file-document-outline", name="B/W counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.bw_counter, ), BrotherSensorEntityDescription( - key=ATTR_COLOR_COUNTER, + key="color_counter", icon="mdi:file-document-outline", name="Color counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.color_counter, ), BrotherSensorEntityDescription( - key=ATTR_DUPLEX_COUNTER, + key="duplex_unit_pages_counter", icon="mdi:file-document-outline", name="Duplex unit pages counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.duplex_unit_pages_counter, ), BrotherSensorEntityDescription( - key=ATTR_DRUM_REMAINING_LIFE, + key="drum_remaining_life", icon="mdi:chart-donut", name="Drum remaining life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.drum_remaining_life, ), BrotherSensorEntityDescription( - key=ATTR_BLACK_DRUM_REMAINING_LIFE, + key="drum_remaining_pages", + icon="mdi:chart-donut", + name="Drum remaining pages", + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.drum_remaining_pages, + ), + BrotherSensorEntityDescription( + key="drum_counter", + icon="mdi:chart-donut", + name="Drum counter", + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.drum_counter, + ), + BrotherSensorEntityDescription( + key="black_drum_remaining_life", icon="mdi:chart-donut", name="Black drum remaining life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.black_drum_remaining_life, + ), + BrotherSensorEntityDescription( + key="black_drum_remaining_pages", + icon="mdi:chart-donut", + name="Black drum remaining pages", + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.black_drum_remaining_pages, + ), + BrotherSensorEntityDescription( + key="black_drum_counter", + icon="mdi:chart-donut", + name="Black drum counter", + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.black_drum_counter, ), BrotherSensorEntityDescription( - key=ATTR_CYAN_DRUM_REMAINING_LIFE, + key="cyan_drum_remaining_life", icon="mdi:chart-donut", name="Cyan drum remaining life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.cyan_drum_remaining_life, ), BrotherSensorEntityDescription( - key=ATTR_MAGENTA_DRUM_REMAINING_LIFE, + key="cyan_drum_remaining_pages", + icon="mdi:chart-donut", + name="Cyan drum remaining pages", + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.cyan_drum_remaining_pages, + ), + BrotherSensorEntityDescription( + key="cyan_drum_counter", + icon="mdi:chart-donut", + name="Cyan drum counter", + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.cyan_drum_counter, + ), + BrotherSensorEntityDescription( + key="magenta_drum_remaining_life", icon="mdi:chart-donut", name="Magenta drum remaining life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.magenta_drum_remaining_life, ), BrotherSensorEntityDescription( - key=ATTR_YELLOW_DRUM_REMAINING_LIFE, + key="magenta_drum_remaining_pages", + icon="mdi:chart-donut", + name="Magenta drum remaining pages", + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.magenta_drum_remaining_pages, + ), + BrotherSensorEntityDescription( + key="magenta_drum_counter", + icon="mdi:chart-donut", + name="Magenta drum counter", + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.magenta_drum_counter, + ), + BrotherSensorEntityDescription( + key="yellow_drum_remaining_life", icon="mdi:chart-donut", name="Yellow drum remaining life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.yellow_drum_remaining_life, + ), + BrotherSensorEntityDescription( + key="yellow_drum_remaining_pages", + icon="mdi:chart-donut", + name="Yellow drum remaining pages", + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.yellow_drum_remaining_pages, + ), + BrotherSensorEntityDescription( + key="yellow_drum_counter", + icon="mdi:chart-donut", + name="Yellow drum counter", + native_unit_of_measurement=UNIT_PAGES, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.yellow_drum_counter, ), BrotherSensorEntityDescription( - key=ATTR_BELT_UNIT_REMAINING_LIFE, + key="belt_unit_remaining_life", icon="mdi:current-ac", name="Belt unit remaining life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.belt_unit_remaining_life, ), BrotherSensorEntityDescription( - key=ATTR_FUSER_REMAINING_LIFE, + key="fuser_remaining_life", icon="mdi:water-outline", name="Fuser remaining life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.fuser_remaining_life, ), BrotherSensorEntityDescription( - key=ATTR_LASER_REMAINING_LIFE, + key="laser_remaining_life", icon="mdi:spotlight-beam", name="Laser remaining life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.laser_remaining_life, ), BrotherSensorEntityDescription( - key=ATTR_PF_KIT_1_REMAINING_LIFE, + key="pf_kit_1_remaining_life", icon="mdi:printer-3d", name="PF Kit 1 remaining life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.pf_kit_1_remaining_life, ), BrotherSensorEntityDescription( - key=ATTR_PF_KIT_MP_REMAINING_LIFE, + key="pf_kit_mp_remaining_life", icon="mdi:printer-3d", name="PF Kit MP remaining life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.pf_kit_mp_remaining_life, ), BrotherSensorEntityDescription( - key=ATTR_BLACK_TONER_REMAINING, + key="black_toner_remaining", icon="mdi:printer-3d-nozzle", name="Black toner remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.black_toner_remaining, ), BrotherSensorEntityDescription( - key=ATTR_CYAN_TONER_REMAINING, + key="cyan_toner_remaining", icon="mdi:printer-3d-nozzle", name="Cyan toner remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.cyan_toner_remaining, ), BrotherSensorEntityDescription( - key=ATTR_MAGENTA_TONER_REMAINING, + key="magenta_toner_remaining", icon="mdi:printer-3d-nozzle", name="Magenta toner remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.magenta_toner_remaining, ), BrotherSensorEntityDescription( - key=ATTR_YELLOW_TONER_REMAINING, + key="yellow_toner_remaining", icon="mdi:printer-3d-nozzle", name="Yellow toner remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.yellow_toner_remaining, ), BrotherSensorEntityDescription( - key=ATTR_BLACK_INK_REMAINING, + key="black_ink_remaining", icon="mdi:printer-3d-nozzle", name="Black ink remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.black_ink_remaining, ), BrotherSensorEntityDescription( - key=ATTR_CYAN_INK_REMAINING, + key="cyan_ink_remaining", icon="mdi:printer-3d-nozzle", name="Cyan ink remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.cyan_ink_remaining, ), BrotherSensorEntityDescription( - key=ATTR_MAGENTA_INK_REMAINING, + key="magenta_ink_remaining", icon="mdi:printer-3d-nozzle", name="Magenta ink remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.magenta_ink_remaining, ), BrotherSensorEntityDescription( - key=ATTR_YELLOW_INK_REMAINING, + key="yellow_ink_remaining", icon="mdi:printer-3d-nozzle", name="Yellow ink remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + value=lambda data: data.yellow_ink_remaining, ), BrotherSensorEntityDescription( - key=ATTR_UPTIME, + key="uptime", name="Uptime", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, - entity_class=BrotherPrinterUptimeSensor, + value=lambda data: data.uptime, ), ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Brother entities from a config_entry.""" + coordinator = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] + + # Due to the change of the attribute name of one sensor, it is necessary to migrate + # the unique_id to the new one. + entity_registry = er.async_get(hass) + old_unique_id = f"{coordinator.data.serial.lower()}_b/w_counter" + if entity_id := entity_registry.async_get_entity_id( + PLATFORM, DOMAIN, old_unique_id + ): + new_unique_id = f"{coordinator.data.serial.lower()}_bw_counter" + _LOGGER.debug( + "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", + entity_id, + old_unique_id, + new_unique_id, + ) + entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + sensors = [] + + device_info = DeviceInfo( + configuration_url=f"http://{entry.data[CONF_HOST]}/", + identifiers={(DOMAIN, coordinator.data.serial)}, + manufacturer="Brother", + model=coordinator.data.model, + name=coordinator.data.model, + sw_version=coordinator.data.firmware, + ) + + for description in SENSOR_TYPES: + if description.value(coordinator.data) is not None: + sensors.append(BrotherPrinterSensor(coordinator, description, device_info)) + async_add_entities(sensors, False) + + +class BrotherPrinterSensor( + CoordinatorEntity[BrotherDataUpdateCoordinator], SensorEntity +): + """Define an Brother Printer sensor.""" + + _attr_has_entity_name = True + entity_description: BrotherSensorEntityDescription + + def __init__( + self, + coordinator: BrotherDataUpdateCoordinator, + description: BrotherSensorEntityDescription, + device_info: DeviceInfo, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_device_info = device_info + self._attr_native_value = description.value(coordinator.data) + self._attr_unique_id = f"{coordinator.data.serial.lower()}_{description.key}" + self.entity_description = description + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = self.entity_description.value(self.coordinator.data) + self.async_write_ha_state() diff --git a/homeassistant/components/brunt/translations/nb.json b/homeassistant/components/brunt/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/brunt/translations/nb.json +++ b/homeassistant/components/brunt/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/brunt/translations/no.json b/homeassistant/components/brunt/translations/no.json index b77151ac92ace867facaa9b7abb8a59c0f71ebc5..78dde9c623454cc14a56a92a8309bd3a923eb7c9 100644 --- a/homeassistant/components/brunt/translations/no.json +++ b/homeassistant/components/brunt/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index c15324825ba164e66c6bc468ccb4c898538ced13..6ee589891503b520ebd278cebc9d697388c94093 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -1,7 +1,7 @@ """The BSB-Lan integration.""" -from datetime import timedelta +import dataclasses -from bsblan import BSBLan, BSBLanConnectionError +from bsblan import BSBLAN, Device, Info, State from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -12,21 +12,29 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_PASSKEY, DATA_BSBLAN_CLIENT, DOMAIN - -SCAN_INTERVAL = timedelta(seconds=30) +from .const import CONF_PASSKEY, DOMAIN, LOGGER, SCAN_INTERVAL PLATFORMS = [Platform.CLIMATE] +@dataclasses.dataclass +class HomeAssistantBSBLANData: + """BSBLan data stored in the Home Assistant data object.""" + + coordinator: DataUpdateCoordinator[State] + client: BSBLAN + device: Device + info: Info + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up BSB-Lan from a config entry.""" session = async_get_clientsession(hass) - bsblan = BSBLan( + bsblan = BSBLAN( entry.data[CONF_HOST], passkey=entry.data[CONF_PASSKEY], port=entry.data[CONF_PORT], @@ -35,13 +43,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=session, ) - try: - await bsblan.info() - except BSBLanConnectionError as exception: - raise ConfigEntryNotReady from exception - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {DATA_BSBLAN_CLIENT: bsblan} + coordinator: DataUpdateCoordinator[State] = DataUpdateCoordinator( + hass, + LOGGER, + name=f"{DOMAIN}_{entry.data[CONF_HOST]}", + update_interval=SCAN_INTERVAL, + update_method=bsblan.state, + ) + await coordinator.async_config_entry_first_refresh() + + device = await bsblan.device() + info = await bsblan.info() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantBSBLANData( + client=bsblan, + coordinator=coordinator, + device=device, + info=info, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -49,13 +67,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload BSBLan config entry.""" - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + """Unload BSBLAN config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): # Cleanup del hass.data[DOMAIN][entry.entry_id] if not hass.data[DOMAIN]: del hass.data[DOMAIN] - return unload_ok diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index b7fa2bb8010b0b531c2c2c276223dda78d25d681..e9774055a8528cbbba70be3ab7393f6441c624b7 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -1,11 +1,9 @@ """BSBLAN platform to control a compatible Climate Device.""" from __future__ import annotations -from datetime import timedelta -import logging from typing import Any -from bsblan import BSBLan, BSBLanError, Info, State +from bsblan import BSBLAN, BSBLANError, Device, Info, State from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -19,15 +17,18 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) -from .const import ATTR_TARGET_TEMPERATURE, DATA_BSBLAN_CLIENT, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from . import HomeAssistantBSBLANData +from .const import ATTR_TARGET_TEMPERATURE, DOMAIN, LOGGER +from .entity import BSBLANEntity PARALLEL_UPDATES = 1 -SCAN_INTERVAL = timedelta(seconds=20) HVAC_MODES = [ HVACMode.AUTO, @@ -40,130 +41,122 @@ PRESET_MODES = [ PRESET_NONE, ] -HA_STATE_TO_BSBLAN = { - HVACMode.AUTO: "1", - HVACMode.HEAT: "3", - HVACMode.OFF: "0", -} - -BSBLAN_TO_HA_STATE = {value: key for key, value in HA_STATE_TO_BSBLAN.items()} - -HA_PRESET_TO_BSBLAN = { - PRESET_ECO: "2", -} - -BSBLAN_TO_HA_PRESET = { - 2: PRESET_ECO, -} - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up BSBLan device based on a config entry.""" - bsblan: BSBLan = hass.data[DOMAIN][entry.entry_id][DATA_BSBLAN_CLIENT] - info = await bsblan.info() - async_add_entities([BSBLanClimate(entry.entry_id, bsblan, info)], True) + """Set up BSBLAN device based on a config entry.""" + data: HomeAssistantBSBLANData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + BSBLANClimate( + data.coordinator, + data.client, + data.device, + data.info, + entry, + ) + ], + True, + ) -class BSBLanClimate(ClimateEntity): - """Defines a BSBLan climate device.""" +class BSBLANClimate(BSBLANEntity, CoordinatorEntity, ClimateEntity): + """Defines a BSBLAN climate device.""" + coordinator: DataUpdateCoordinator[State] + _attr_has_entity_name = True + # Determine preset modes _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) - _attr_hvac_modes = HVAC_MODES _attr_preset_modes = PRESET_MODES + # Determine hvac modes + _attr_hvac_modes = HVAC_MODES + def __init__( self, - entry_id: str, - bsblan: BSBLan, + coordinator: DataUpdateCoordinator, + client: BSBLAN, + device: Device, info: Info, + entry: ConfigEntry, ) -> None: - """Initialize BSBLan climate device.""" - self._attr_available = True - self._store_hvac_mode: HVACMode | str | None = None - self.bsblan = bsblan - self._attr_name = self._attr_unique_id = info.device_identification - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, info.device_identification)}, - manufacturer="BSBLan", - model=info.controller_variant, - name="BSBLan Device", + """Initialize BSBLAN climate device.""" + super().__init__(client, device, info, entry) + CoordinatorEntity.__init__(self, coordinator) + self._attr_unique_id = f"{format_mac(device.MAC)}-climate" + + self._attr_min_temp = float(self.coordinator.data.min_temp.value) + self._attr_max_temp = float(self.coordinator.data.max_temp.value) + self._attr_temperature_unit = ( + TEMP_CELSIUS + if self.coordinator.data.current_temperature.unit == "°C" + else TEMP_FAHRENHEIT ) + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return float(self.coordinator.data.current_temperature.value) + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return float(self.coordinator.data.target_temperature.value) + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + if self.coordinator.data.hvac_mode.value == PRESET_ECO: + return HVACMode.AUTO + + return self.coordinator.data.hvac_mode.value + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + if ( + self.hvac_mode == HVACMode.AUTO + and self.coordinator.data.hvac_mode.value == PRESET_ECO + ): + return PRESET_ECO + return PRESET_NONE + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set hvac mode.""" + await self.async_set_data(hvac_mode=hvac_mode) + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" - _LOGGER.debug("Setting preset mode to: %s", preset_mode) - if preset_mode == PRESET_NONE: - # restore previous hvac mode - self._attr_hvac_mode = self._store_hvac_mode - else: - # Store hvac mode. - self._store_hvac_mode = self._attr_hvac_mode + # only allow preset mode when hvac mode is auto + if self.hvac_mode == HVACMode.AUTO: await self.async_set_data(preset_mode=preset_mode) - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set HVAC mode.""" - _LOGGER.debug("Setting HVAC mode to: %s", hvac_mode) - # preset should be none when hvac mode is set - self._attr_preset_mode = PRESET_NONE - await self.async_set_data(hvac_mode=hvac_mode) + else: + LOGGER.error("Can't set preset mode when hvac mode is not auto") async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" await self.async_set_data(**kwargs) async def async_set_data(self, **kwargs: Any) -> None: - """Set device settings using BSBLan.""" + """Set device settings using BSBLAN.""" data = {} - if ATTR_TEMPERATURE in kwargs: data[ATTR_TARGET_TEMPERATURE] = kwargs[ATTR_TEMPERATURE] - _LOGGER.debug("Set temperature data = %s", data) - if ATTR_HVAC_MODE in kwargs: - data[ATTR_HVAC_MODE] = HA_STATE_TO_BSBLAN[kwargs[ATTR_HVAC_MODE]] - _LOGGER.debug("Set hvac mode data = %s", data) - + data[ATTR_HVAC_MODE] = kwargs[ATTR_HVAC_MODE] if ATTR_PRESET_MODE in kwargs: - # for now we set the preset as hvac_mode as the api expect this - data[ATTR_HVAC_MODE] = HA_PRESET_TO_BSBLAN[kwargs[ATTR_PRESET_MODE]] - + # If preset mode is None, set hvac to auto + if kwargs[ATTR_PRESET_MODE] == PRESET_NONE: + data[ATTR_HVAC_MODE] = HVACMode.AUTO + else: + data[ATTR_HVAC_MODE] = kwargs[ATTR_PRESET_MODE] try: - await self.bsblan.thermostat(**data) - except BSBLanError: - _LOGGER.error("An error occurred while updating the BSBLan device") - self._attr_available = False - - async def async_update(self) -> None: - """Update BSBlan entity.""" - try: - state: State = await self.bsblan.state() - except BSBLanError: - if self.available: - _LOGGER.error("An error occurred while updating the BSBLan device") - self._attr_available = False - return - - self._attr_available = True - - self._attr_current_temperature = float(state.current_temperature.value) - self._attr_target_temperature = float(state.target_temperature.value) - - # check if preset is active else get hvac mode - _LOGGER.debug("state hvac/preset mode: %s", state.hvac_mode.value) - if state.hvac_mode.value == "2": - self._attr_preset_mode = PRESET_ECO - else: - self._attr_hvac_mode = BSBLAN_TO_HA_STATE[state.hvac_mode.value] - self._attr_preset_mode = PRESET_NONE - - self._attr_temperature_unit = ( - TEMP_CELSIUS - if state.current_temperature.unit == "°C" - else TEMP_FAHRENHEIT - ) + await self.client.thermostat(**data) + except BSBLANError: + LOGGER.error("An error occurred while updating the BSBLAN device") + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index dff7773910662fc39159a3f5a3a8878314051c91..e12e6e5c6cfab8b9fffc36baf9efe40baa4331d4 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -1,27 +1,33 @@ """Config flow for BSB-Lan integration.""" from __future__ import annotations -import logging from typing import Any -from bsblan import BSBLan, BSBLanError, Info +from bsblan import BSBLAN, BSBLANError import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac -from .const import CONF_DEVICE_IDENT, CONF_PASSKEY, DOMAIN +from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN -_LOGGER = logging.getLogger(__name__) - -class BSBLanFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle a BSBLan config flow.""" +class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a BSBLAN config flow.""" VERSION = 1 + host: str + port: int + mac: str + passkey: str | None = None + username: str | None = None + password: str | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -29,33 +35,20 @@ class BSBLanFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self._show_setup_form() + self.host = user_input[CONF_HOST] + self.port = user_input[CONF_PORT] + self.passkey = user_input.get(CONF_PASSKEY) + self.username = user_input.get(CONF_USERNAME) + self.password = user_input.get(CONF_PASSWORD) + try: - info = await self._get_bsblan_info( - host=user_input[CONF_HOST], - port=user_input[CONF_PORT], - passkey=user_input.get(CONF_PASSKEY), - username=user_input.get(CONF_USERNAME), - password=user_input.get(CONF_PASSWORD), - ) - except BSBLanError: + await self._get_bsblan_info() + except BSBLANError: return self._show_setup_form({"base": "cannot_connect"}) - # Check if already configured - await self.async_set_unique_id(info.device_identification) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=info.device_identification, - data={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - CONF_PASSKEY: user_input.get(CONF_PASSKEY), - CONF_DEVICE_IDENT: info.device_identification, - CONF_USERNAME: user_input.get(CONF_USERNAME), - CONF_PASSWORD: user_input.get(CONF_PASSWORD), - }, - ) + return self._async_create_entry() + @callback def _show_setup_form(self, errors: dict | None = None) -> FlowResult: """Show the setup form to the user.""" return self.async_show_form( @@ -63,7 +56,7 @@ class BSBLanFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_HOST): str, - vol.Optional(CONF_PORT, default=80): int, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, vol.Optional(CONF_PASSKEY): str, vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str, @@ -72,23 +65,39 @@ class BSBLanFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def _get_bsblan_info( - self, - host: str, - username: str | None, - password: str | None, - passkey: str | None, - port: int, - ) -> Info: - """Get device information from an BSBLan device.""" + @callback + def _async_create_entry(self) -> FlowResult: + return self.async_create_entry( + title=format_mac(self.mac), + data={ + CONF_HOST: self.host, + CONF_PORT: self.port, + CONF_PASSKEY: self.passkey, + CONF_USERNAME: self.username, + CONF_PASSWORD: self.password, + }, + ) + + async def _get_bsblan_info(self, raise_on_progress: bool = True) -> None: + """Get device information from an BSBLAN device.""" session = async_get_clientsession(self.hass) - _LOGGER.debug("request bsblan.info:") - bsblan = BSBLan( - host, - username=username, - password=password, - passkey=passkey, - port=port, + bsblan = BSBLAN( + host=self.host, + username=self.username, + password=self.password, + passkey=self.passkey, + port=self.port, session=session, ) - return await bsblan.info() + device = await bsblan.device() + self.mac = device.MAC + + await self.async_set_unique_id( + format_mac(self.mac), raise_on_progress=raise_on_progress + ) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.host, + CONF_PORT: self.port, + } + ) diff --git a/homeassistant/components/bsblan/const.py b/homeassistant/components/bsblan/const.py index 0dc2e15a7b46d28264ccacb689ba561d81a7b1de..0de9a29a27be8f524d0caf75b5439524b78fd411 100644 --- a/homeassistant/components/bsblan/const.py +++ b/homeassistant/components/bsblan/const.py @@ -1,23 +1,25 @@ """Constants for the BSB-Lan integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging from typing import Final -DOMAIN = "bsblan" +# Integration domain +DOMAIN: Final = "bsblan" + +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(seconds=12) +# Services DATA_BSBLAN_CLIENT: Final = "bsblan_client" -DATA_BSBLAN_TIMER: Final = "bsblan_timer" -DATA_BSBLAN_UPDATED: Final = "bsblan_updated" ATTR_TARGET_TEMPERATURE: Final = "target_temperature" ATTR_INSIDE_TEMPERATURE: Final = "inside_temperature" ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature" -ATTR_STATE_ON: Final = "on" -ATTR_STATE_OFF: Final = "off" - -CONF_DEVICE_IDENT: Final = "device_identification" -CONF_CONTROLLER_FAM: Final = "controller_family" -CONF_CONTROLLER_VARI: Final = "controller_variant" +CONF_PASSKEY: Final = "passkey" -SENSOR_TYPE_TEMPERATURE: Final = "temperature" +CONF_DEVICE_IDENT: Final = "RVS21.831F/127" -CONF_PASSKEY: Final = "passkey" +DEFAULT_PORT: Final = 80 diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py new file mode 100644 index 0000000000000000000000000000000000000000..3e8a493d53bd3f1d940f91248a2d0f742030627a --- /dev/null +++ b/homeassistant/components/bsblan/entity.py @@ -0,0 +1,34 @@ +"""Base entity for the BSBLAN integration.""" +from __future__ import annotations + +from bsblan import BSBLAN, Device, Info + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +class BSBLANEntity(Entity): + """Defines a BSBLAN entity.""" + + def __init__( + self, + client: BSBLAN, + device: Device, + info: Info, + entry: ConfigEntry, + ) -> None: + """Initialize an BSBLAN entity.""" + self.client = client + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, format_mac(device.MAC))}, + manufacturer="BSBLAN Inc.", + model=info.device_identification.value, + name=device.name, + sw_version=f"{device.version})", + configuration_url=f"http://{entry.data[CONF_HOST]}", + ) diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 88eefb7f9c023cbcbde4a800382fae175e0c27e1..7c5422d3eff32a065575a1277275c125ee57467b 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -3,7 +3,7 @@ "name": "BSB-Lan", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bsblan", - "requirements": ["bsblan==0.5.0"], + "requirements": ["python-bsblan==0.5.5"], "codeowners": ["@liudger"], "iot_class": "local_polling", "loggers": ["bsblan"] diff --git a/homeassistant/components/bswitch/manifest.json b/homeassistant/components/bswitch/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..62c6efb5bcbede5bfcf95fc6765b5d8deda9c7fa --- /dev/null +++ b/homeassistant/components/bswitch/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "bswitch", + "name": "BSwitch", + "integration_type": "virtual", + "supported_by": "switchbee" +} diff --git a/homeassistant/components/bthome/translations/he.json b/homeassistant/components/bthome/translations/he.json index 47308062d0d426cb13dd9e46494762b2e48d2482..b90a366130ab0c23f45d7346bd344eab436b4819 100644 --- a/homeassistant/components/bthome/translations/he.json +++ b/homeassistant/components/bthome/translations/he.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, "flow_title": "{name}", "step": { "bluetooth_confirm": { diff --git a/homeassistant/components/bthome/translations/hu.json b/homeassistant/components/bthome/translations/hu.json index 1bf4fffab68be552775042491a789952d3e33641..11a8592dbe53ca896c6f437e0a96a156c15a2036 100644 --- a/homeassistant/components/bthome/translations/hu.json +++ b/homeassistant/components/bthome/translations/hu.json @@ -25,7 +25,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/bthome/translations/no.json b/homeassistant/components/bthome/translations/no.json index ba68150db4cade4c14d9d8213b674345d6ed665f..84f7e7853dfc14696bbc341ef61eb48f2556c81d 100644 --- a/homeassistant/components/bthome/translations/no.json +++ b/homeassistant/components/bthome/translations/no.json @@ -4,7 +4,7 @@ "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "decryption_failed": "Den oppgitte bindingsn\u00f8kkelen fungerte ikke, sensordata kunne ikke dekrypteres. Vennligst sjekk det og pr\u00f8v igjen.", diff --git a/homeassistant/components/bticino/manifest.json b/homeassistant/components/bticino/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..39b618ad49b93509c8c549583b5e9409e52df7ea --- /dev/null +++ b/homeassistant/components/bticino/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "bticino", + "name": "BTicino", + "integration_type": "virtual", + "supported_by": "netatmo" +} diff --git a/homeassistant/components/bubendorff/manifest.json b/homeassistant/components/bubendorff/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..599dace52361a31bf5cf3ccb5850eb5ef46e482f --- /dev/null +++ b/homeassistant/components/bubendorff/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "bubendorff", + "name": "Bubendorff", + "integration_type": "virtual", + "supported_by": "netatmo" +} diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 279fdc145d5e6fc3441e1921f34bc5db3945a7b5..bf44c884147e87598a80b223a5e3fcc796bb00e1 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -38,10 +38,10 @@ from homeassistant.const import ( LENGTH_KILOMETERS, LENGTH_MILLIMETERS, PERCENTAGE, - PRECIPITATION_MILLIMETERS_PER_HOUR, PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, + UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -183,9 +183,9 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="precipitation", name="Precipitation", - native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, - icon="mdi:weather-pouring", + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key="irradiance", @@ -197,8 +197,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="precipitation_forecast_average", name="Precipitation forecast average", - native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, - icon="mdi:weather-pouring", + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key="precipitation_forecast_total", diff --git a/homeassistant/components/buienradar/translations/bg.json b/homeassistant/components/buienradar/translations/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..ca1a967a7ea13fa87e95a748e98e481eed95c534 --- /dev/null +++ b/homeassistant/components/buienradar/translations/bg.json @@ -0,0 +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" + }, + "error": { + "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", + "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index cfbe038c251af6e119fb66517294ce2cf5568f5d..cfc09df667a079ce7b03676b15ab7e8274153e10 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -1,7 +1,8 @@ """Support for Google Calendar event device sensors.""" from __future__ import annotations -from dataclasses import dataclass +from collections.abc import Iterable +import dataclasses import datetime from http import HTTPStatus import logging @@ -77,7 +78,7 @@ def get_date(date: dict[str, Any]) -> datetime.datetime: return dt.as_local(parsed_datetime) -@dataclass +@dataclasses.dataclass class CalendarEvent: """An event on a calendar.""" @@ -104,17 +105,34 @@ class CalendarEvent: def as_dict(self) -> dict[str, Any]: """Return a dict representation of the event.""" - data = { - "start": self.start.isoformat(), - "end": self.end.isoformat(), - "summary": self.summary, + return { + **dataclasses.asdict(self, dict_factory=_event_dict_factory), "all_day": self.all_day, } - if self.description: - data["description"] = self.description - if self.location: - data["location"] = self.location - return data + + +def _event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: + """Convert CalendarEvent dataclass items to dictionary of attributes.""" + result: dict[str, str] = {} + for name, value in obj: + if isinstance(value, (datetime.datetime, datetime.date)): + result[name] = value.isoformat() + elif value is not None: + result[name] = str(value) + return result + + +def _api_event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, Any]: + """Convert CalendarEvent dataclass items to the API format.""" + result: dict[str, Any] = {} + for name, value in obj: + if isinstance(value, datetime.datetime): + result[name] = {"dateTime": dt.as_local(value).isoformat()} + elif isinstance(value, datetime.date): + result[name] = {"date": value.isoformat()} + else: + result[name] = value + return result def _get_datetime_local( @@ -133,32 +151,6 @@ def _get_api_date(dt_or_d: datetime.datetime | datetime.date) -> dict[str, str]: return {"date": dt_or_d.isoformat()} -def normalize_event(event: dict[str, Any]) -> dict[str, Any]: - """Normalize a calendar event.""" - normalized_event: dict[str, Any] = {} - - start = event.get("start") - end = event.get("end") - start = get_date(start) if start is not None else None - end = get_date(end) if end is not None else None - normalized_event["dt_start"] = start - normalized_event["dt_end"] = end - - start = start.strftime(DATE_STR_FORMAT) if start is not None else None - end = end.strftime(DATE_STR_FORMAT) if end is not None else None - normalized_event["start"] = start - normalized_event["end"] = end - - # cleanup the string so we don't have a bunch of double+ spaces - summary = event.get("summary", "") - normalized_event["message"] = re.sub(" +", "", summary).strip() - normalized_event["location"] = event.get("location", "") - normalized_event["description"] = event.get("description", "") - normalized_event["all_day"] = "date" in event["start"] - - return normalized_event - - def extract_offset(summary: str, offset_prefix: str) -> tuple[str, datetime.timedelta]: """Extract the offset from the event summary. @@ -276,15 +268,10 @@ class CalendarEventView(http.HomeAssistantView): return self.json_message( f"Error reading events: {err}", HTTPStatus.INTERNAL_SERVER_ERROR ) + return self.json( [ - { - "summary": event.summary, - "description": event.description, - "location": event.location, - "start": _get_api_date(event.start), - "end": _get_api_date(event.end), - } + dataclasses.asdict(event, dict_factory=_api_event_dict_factory) for event in calendar_event_list ] ) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index fa807dd14406dc42e88e96d226f6c78613ea99b5..d860776a7970781f2f3fc5064da6e1a2478d9968 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -12,7 +12,7 @@ from functools import partial import logging import os from random import SystemRandom -from typing import Final, Optional, cast, final +from typing import Any, Final, Optional, cast, final from aiohttp import hdrs, web import async_timeout @@ -786,7 +786,7 @@ class CameraMjpegStream(CameraView): ) @websocket_api.async_response async def ws_camera_stream( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get camera stream websocket command. @@ -816,7 +816,7 @@ async def ws_camera_stream( ) @websocket_api.async_response async def ws_camera_web_rtc_offer( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle the signal path for a WebRTC stream. @@ -856,7 +856,7 @@ async def ws_camera_web_rtc_offer( ) @websocket_api.async_response async def websocket_get_prefs( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle request for account info.""" prefs = hass.data[DATA_CAMERA_PREFS].get(msg["entity_id"]) @@ -873,7 +873,7 @@ async def websocket_get_prefs( ) @websocket_api.async_response async def websocket_update_prefs( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle request for account info.""" prefs = hass.data[DATA_CAMERA_PREFS] diff --git a/homeassistant/components/camera/media_source.py b/homeassistant/components/camera/media_source.py index e386e864ded011bc0bb8d9162108269abfa633ea..e681ddbbd7e3095575f2298fd903878836250113 100644 --- a/homeassistant/components/camera/media_source.py +++ b/homeassistant/components/camera/media_source.py @@ -46,15 +46,17 @@ class CameraMediaSource(MediaSource): f"/api/camera_proxy_stream/{camera.entity_id}", camera.content_type ) - if stream_type != StreamType.HLS: - raise Unresolvable("Camera does not support MJPEG or HLS streaming.") - if "stream" not in self.hass.config.components: raise Unresolvable("Stream integration not loaded") try: url = await _async_stream_endpoint_url(self.hass, camera, HLS_PROVIDER) except HomeAssistantError as err: + # Handle known error + if stream_type != StreamType.HLS: + raise Unresolvable( + "Camera does not support MJPEG or HLS streaming." + ) from err raise Unresolvable(str(err)) from err return PlayMedia(url, FORMAT_CONTENT_TYPE[HLS_PROVIDER]) diff --git a/homeassistant/components/canary/translations/nb.json b/homeassistant/components/canary/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/canary/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/channels/manifest.json b/homeassistant/components/channels/manifest.json index d167d6b420788b648b613d083b699a9f2ae3cf61..54e06fdc3abde01469d064b8433fd02632d5a868 100644 --- a/homeassistant/components/channels/manifest.json +++ b/homeassistant/components/channels/manifest.json @@ -2,7 +2,7 @@ "domain": "channels", "name": "Channels", "documentation": "https://www.home-assistant.io/integrations/channels", - "requirements": ["pychannels==1.0.0"], + "requirements": ["pychannels==1.2.3"], "codeowners": [], "iot_class": "local_polling", "loggers": ["pychannels"] diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 5074378adca8bdd45670a668931a88bc0304522d..5da74167a0b1c657f1ab4d258909a806c22d25be 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -37,6 +37,7 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import location from homeassistant.util.unit_conversion import DistanceConverter +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM _LOGGER = logging.getLogger(__name__) @@ -170,7 +171,7 @@ async def async_setup_platform( stations_list = set(config.get(CONF_STATIONS_LIST, [])) radius = config.get(CONF_RADIUS, 0) name = config[CONF_NAME] - if not hass.config.units.is_metric: + if hass.config.units is US_CUSTOMARY_SYSTEM: radius = DistanceConverter.convert(radius, LENGTH_FEET, LENGTH_METERS) # Create a single instance of CityBikesNetworks. diff --git a/homeassistant/components/clickatell/notify.py b/homeassistant/components/clickatell/notify.py index fdefb25aef4506c8c733f93c4337521093f72333..8422f7295b3ab5b8dd05aaaf3793ff7147dbb213 100644 --- a/homeassistant/components/clickatell/notify.py +++ b/homeassistant/components/clickatell/notify.py @@ -1,13 +1,18 @@ """Clickatell platform for notify component.""" +from __future__ import annotations + from http import HTTPStatus import logging +from typing import Any import requests import voluptuous as vol from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService from homeassistant.const import CONF_API_KEY, CONF_RECIPIENT +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -20,7 +25,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def get_service(hass, config, discovery_info=None): +def get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> ClickatellNotificationService: """Get the Clickatell notification service.""" return ClickatellNotificationService(config) @@ -28,12 +37,12 @@ def get_service(hass, config, discovery_info=None): class ClickatellNotificationService(BaseNotificationService): """Implementation of a notification service for the Clickatell service.""" - def __init__(self, config): + def __init__(self, config: ConfigType) -> None: """Initialize the service.""" - self.api_key = config[CONF_API_KEY] - self.recipient = config[CONF_RECIPIENT] + self.api_key: str = config[CONF_API_KEY] + self.recipient: str = config[CONF_RECIPIENT] - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" data = {"apiKey": self.api_key, "to": self.recipient, "content": message} diff --git a/homeassistant/components/clicksend/notify.py b/homeassistant/components/clicksend/notify.py index ec6bed3c55d36b06334395f4d02166d4be568795..36ac21d8dd3a29c2d6f848e717c4deb2c3a34994 100644 --- a/homeassistant/components/clicksend/notify.py +++ b/homeassistant/components/clicksend/notify.py @@ -1,7 +1,10 @@ """Clicksend platform for notify component.""" +from __future__ import annotations + from http import HTTPStatus import json import logging +from typing import Any import requests import voluptuous as vol @@ -14,7 +17,9 @@ from homeassistant.const import ( CONF_USERNAME, CONTENT_TYPE_JSON, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -41,7 +46,11 @@ PLATFORM_SCHEMA = vol.Schema( ) -def get_service(hass, config, discovery_info=None): +def get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> ClicksendNotificationService | None: """Get the ClickSend notification service.""" if not _authenticate(config): _LOGGER.error("You are not authorized to access ClickSend") @@ -52,16 +61,16 @@ def get_service(hass, config, discovery_info=None): class ClicksendNotificationService(BaseNotificationService): """Implementation of a notification service for the ClickSend service.""" - def __init__(self, config): + def __init__(self, config: ConfigType) -> None: """Initialize the service.""" - self.username = config[CONF_USERNAME] - self.api_key = config[CONF_API_KEY] - self.recipients = config[CONF_RECIPIENT] - self.sender = config[CONF_SENDER] + self.username: str = config[CONF_USERNAME] + self.api_key: str = config[CONF_API_KEY] + self.recipients: list[str] = config[CONF_RECIPIENT] + self.sender: str = config[CONF_SENDER] - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" - data = {"messages": []} + data: dict[str, Any] = {"messages": []} for recipient in self.recipients: data["messages"].append( { @@ -91,7 +100,7 @@ class ClicksendNotificationService(BaseNotificationService): ) -def _authenticate(config): +def _authenticate(config: ConfigType) -> bool: """Authenticate with ClickSend.""" api_url = f"{BASE_API_URL}/account" resp = requests.get( diff --git a/homeassistant/components/clicksend_tts/notify.py b/homeassistant/components/clicksend_tts/notify.py index 8026c8e150be1cd27b9809ebc88e2261ae476409..5ff38c41fc9cee62aef1228fefa068b4b294c6b5 100644 --- a/homeassistant/components/clicksend_tts/notify.py +++ b/homeassistant/components/clicksend_tts/notify.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService from homeassistant.const import ( CONF_API_KEY, + CONF_NAME, CONF_RECIPIENT, CONF_USERNAME, CONTENT_TYPE_JSON, @@ -23,20 +24,27 @@ HEADERS = {"Content-Type": CONTENT_TYPE_JSON} CONF_LANGUAGE = "language" CONF_VOICE = "voice" -CONF_CALLER = "caller" +MALE_VOICE = "male" +FEMALE_VOICE = "female" + +DEFAULT_NAME = "clicksend_tts" DEFAULT_LANGUAGE = "en-us" -DEFAULT_VOICE = "female" +DEFAULT_VOICE = FEMALE_VOICE TIMEOUT = 5 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_RECIPIENT): cv.string, + vol.Required(CONF_RECIPIENT): vol.All( + cv.string, vol.Match(r"^\+?[1-9]\d{1,14}$") + ), vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): cv.string, - vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): cv.string, - vol.Optional(CONF_CALLER): cv.string, + vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): vol.In( + [MALE_VOICE, FEMALE_VOICE] + ), } ) @@ -60,9 +68,6 @@ class ClicksendNotificationService(BaseNotificationService): self.recipient = config[CONF_RECIPIENT] self.language = config[CONF_LANGUAGE] self.voice = config[CONF_VOICE] - self.caller = config.get(CONF_CALLER) - if self.caller is None: - self.caller = self.recipient def send_message(self, message="", **kwargs): """Send a voice call to a user.""" @@ -70,7 +75,6 @@ class ClicksendNotificationService(BaseNotificationService): "messages": [ { "source": "hass.notify", - "from": self.caller, "to": self.recipient, "body": message, "lang": self.language, diff --git a/homeassistant/components/climate/translations/da.json b/homeassistant/components/climate/translations/da.json index 18b2bf16d49351965d7667ff322b514d21a715e8..e637e873e4243111e099b6d299400bcfb9bd48df 100644 --- a/homeassistant/components/climate/translations/da.json +++ b/homeassistant/components/climate/translations/da.json @@ -17,12 +17,12 @@ "state": { "_": { "auto": "Auto", - "cool": "K\u00f8l", - "dry": "T\u00f8r", + "cool": "Afk\u00f8ling", + "dry": "Affugtning", "fan_only": "Kun bl\u00e6ser", - "heat": "Varme", + "heat": "Opvarmning", "heat_cool": "Opvarm/k\u00f8l", - "off": "Fra" + "off": "Slukket" } }, "title": "Klima" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index ebeb79dcd2ac815c49527dff2aa390a79af67017..01b6cd17508973c0584bb5beb29345af9d924dfe 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -4,6 +4,7 @@ import dataclasses from functools import wraps from http import HTTPStatus import logging +from typing import Any import aiohttp import async_timeout @@ -282,7 +283,11 @@ class CloudForgotPasswordView(HomeAssistantView): @websocket_api.websocket_command({vol.Required("type"): "cloud/status"}) @websocket_api.async_response -async def websocket_cloud_status(hass, connection, msg): +async def websocket_cloud_status( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle request for account info. Async friendly. @@ -316,7 +321,11 @@ def _require_cloud_login(handler): @_require_cloud_login @websocket_api.websocket_command({vol.Required("type"): "cloud/subscription"}) @websocket_api.async_response -async def websocket_subscription(hass, connection, msg): +async def websocket_subscription( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle request for account info.""" cloud = hass.data[DOMAIN] try: @@ -347,7 +356,11 @@ async def websocket_subscription(hass, connection, msg): } ) @websocket_api.async_response -async def websocket_update_prefs(hass, connection, msg): +async def websocket_update_prefs( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle request for account info.""" cloud = hass.data[DOMAIN] @@ -392,7 +405,11 @@ async def websocket_update_prefs(hass, connection, msg): ) @websocket_api.async_response @_ws_handle_cloud_errors -async def websocket_hook_create(hass, connection, msg): +async def websocket_hook_create( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle request for account info.""" cloud = hass.data[DOMAIN] hook = await cloud.cloudhooks.async_create(msg["webhook_id"], False) @@ -408,7 +425,11 @@ async def websocket_hook_create(hass, connection, msg): ) @websocket_api.async_response @_ws_handle_cloud_errors -async def websocket_hook_delete(hass, connection, msg): +async def websocket_hook_delete( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle request for account info.""" cloud = hass.data[DOMAIN] await cloud.cloudhooks.async_delete(msg["webhook_id"]) @@ -470,7 +491,11 @@ async def _account_data(hass: HomeAssistant, cloud: Cloud): @websocket_api.websocket_command({"type": "cloud/remote/connect"}) @websocket_api.async_response @_ws_handle_cloud_errors -async def websocket_remote_connect(hass, connection, msg): +async def websocket_remote_connect( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle request for connect remote.""" cloud = hass.data[DOMAIN] await cloud.client.prefs.async_update(remote_enabled=True) @@ -482,7 +507,11 @@ async def websocket_remote_connect(hass, connection, msg): @websocket_api.websocket_command({"type": "cloud/remote/disconnect"}) @websocket_api.async_response @_ws_handle_cloud_errors -async def websocket_remote_disconnect(hass, connection, msg): +async def websocket_remote_disconnect( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle request for disconnect remote.""" cloud = hass.data[DOMAIN] await cloud.client.prefs.async_update(remote_enabled=False) @@ -494,7 +523,11 @@ async def websocket_remote_disconnect(hass, connection, msg): @websocket_api.websocket_command({"type": "cloud/google_assistant/entities"}) @websocket_api.async_response @_ws_handle_cloud_errors -async def google_assistant_list(hass, connection, msg): +async def google_assistant_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """List all google assistant entities.""" cloud = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() @@ -528,7 +561,11 @@ async def google_assistant_list(hass, connection, msg): ) @websocket_api.async_response @_ws_handle_cloud_errors -async def google_assistant_update(hass, connection, msg): +async def google_assistant_update( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Update google assistant config.""" cloud = hass.data[DOMAIN] changes = dict(msg) @@ -547,7 +584,11 @@ async def google_assistant_update(hass, connection, msg): @websocket_api.websocket_command({"type": "cloud/alexa/entities"}) @websocket_api.async_response @_ws_handle_cloud_errors -async def alexa_list(hass, connection, msg): +async def alexa_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """List all alexa entities.""" cloud = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() @@ -578,7 +619,11 @@ async def alexa_list(hass, connection, msg): ) @websocket_api.async_response @_ws_handle_cloud_errors -async def alexa_update(hass, connection, msg): +async def alexa_update( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Update alexa entity config.""" cloud = hass.data[DOMAIN] changes = dict(msg) @@ -596,7 +641,11 @@ async def alexa_update(hass, connection, msg): @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/alexa/sync"}) @websocket_api.async_response -async def alexa_sync(hass, connection, msg): +async def alexa_sync( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Sync with Alexa.""" cloud = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() @@ -622,7 +671,11 @@ async def alexa_sync(hass, connection, msg): @websocket_api.websocket_command({"type": "cloud/thingtalk/convert", "query": str}) @websocket_api.async_response -async def thingtalk_convert(hass, connection, msg): +async def thingtalk_convert( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Convert a query.""" cloud = hass.data[DOMAIN] @@ -636,7 +689,11 @@ async def thingtalk_convert(hass, connection, msg): @websocket_api.websocket_command({"type": "cloud/tts/info"}) -def tts_info(hass, connection, msg): +def tts_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Fetch available tts info.""" connection.send_result( msg["id"], {"languages": [(lang, gender.value) for lang, gender in MAP_VOICE]} diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 3a6d942f5eac8f330c294bec0803a24b756fe830..97f581d3bf0bcca91c2e8b17a94f66c114a36d8d 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -7,5 +7,6 @@ "after_dependencies": ["google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], "iot_class": "cloud_push", - "loggers": ["hass_nabucasa"] + "loggers": ["hass_nabucasa"], + "integration_type": "system" } diff --git a/homeassistant/components/cloudflare/translations/nb.json b/homeassistant/components/cloudflare/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/cloudflare/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cloudflare/translations/no.json b/homeassistant/components/cloudflare/translations/no.json index 1329429474ae22d25e29ae2c548c763525d9d8ed..00792b4b83cb2f135e464f08fe39209c3bf56c03 100644 --- a/homeassistant/components/cloudflare/translations/no.json +++ b/homeassistant/components/cloudflare/translations/no.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig.", "unknown": "Uventet feil" }, diff --git a/homeassistant/components/co2signal/translations/bg.json b/homeassistant/components/co2signal/translations/bg.json index bb253fb6e6b4040378bd0aceecf260bc151ed263..43f43e3ae911c8703132bd6a0adc2d212c676232 100644 --- a/homeassistant/components/co2signal/translations/bg.json +++ b/homeassistant/components/co2signal/translations/bg.json @@ -23,7 +23,8 @@ "user": { "data": { "location": "\u041f\u043e\u043b\u0443\u0447\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 \u0434\u0430\u043d\u043d\u0438 \u0437\u0430" - } + }, + "description": "\u041f\u043e\u0441\u0435\u0442\u0435\u0442\u0435 https://co2signal.com/ \u0437\u0430 \u0434\u0430 \u0437\u0430\u044f\u0432\u0438\u0442\u0435 \u0442\u043e\u043a\u044a\u043d." } } } diff --git a/homeassistant/components/co2signal/translations/nb.json b/homeassistant/components/co2signal/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/co2signal/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index 36ce65517dbe3b99be23d445c86152d9730e9b9e..ecba1900b6415e38259af75b416ec357abf441bf 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -6,14 +6,12 @@ import logging from coinbase.wallet.client import Client from coinbase.wallet.error import AuthenticationError -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from .const import ( @@ -22,7 +20,6 @@ from .const import ( CONF_CURRENCIES, CONF_EXCHANGE_BASE, CONF_EXCHANGE_RATES, - CONF_YAML_API_TOKEN, DOMAIN, ) @@ -32,37 +29,7 @@ PLATFORMS = [Platform.SENSOR] MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) -CONFIG_SCHEMA = vol.Schema( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_YAML_API_TOKEN): cv.string, - vol.Optional(CONF_CURRENCIES): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_EXCHANGE_RATES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - }, - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Coinbase component.""" - if DOMAIN not in config: - return True - 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, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 6582acc6549e571068cc72f23ff2177381ba6a35..5dc60f535d74276c26ac20519e27f8b41a73b017 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from coinbase.wallet.client import Client from coinbase.wallet.error import AuthenticationError @@ -10,6 +11,7 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from . import get_accounts @@ -23,8 +25,6 @@ from .const import ( CONF_EXCHANGE_PRECISION, CONF_EXCHANGE_PRECISION_DEFAULT, CONF_EXCHANGE_RATES, - CONF_OPTIONS, - CONF_YAML_API_TOKEN, DOMAIN, RATES, WALLETS, @@ -104,9 +104,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=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 None: return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors @@ -114,11 +116,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_API_KEY: user_input[CONF_API_KEY]}) - options = {} - - if CONF_OPTIONS in user_input: - options = user_input.pop(CONF_OPTIONS) - try: info = await validate_api(self.hass, user_input) except CannotConnect: @@ -133,33 +130,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_create_entry( - title=info["title"], data=user_input, options=options - ) + return self.async_create_entry(title=info["title"], data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_import(self, config): - """Handle import of Coinbase config from YAML.""" - - cleaned_data = { - CONF_API_KEY: config[CONF_API_KEY], - CONF_API_TOKEN: config[CONF_YAML_API_TOKEN], - } - cleaned_data[CONF_OPTIONS] = { - CONF_CURRENCIES: [], - CONF_EXCHANGE_RATES: [], - } - if CONF_CURRENCIES in config: - cleaned_data[CONF_OPTIONS][CONF_CURRENCIES] = config[CONF_CURRENCIES] - if CONF_EXCHANGE_RATES in config: - cleaned_data[CONF_OPTIONS][CONF_EXCHANGE_RATES] = config[ - CONF_EXCHANGE_RATES - ] - - return await self.async_step_user(user_input=cleaned_data) - @staticmethod @callback def async_get_options_flow( @@ -176,7 +151,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Initialize options flow.""" 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 = {} diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index 48d4b1a630780414b27a4f7822bc852ea5ab3346..13415147ef932bcd5d01a9ff933968967837adb5 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -5,13 +5,9 @@ CONF_EXCHANGE_BASE = "exchange_base" CONF_EXCHANGE_RATES = "exchange_rate_currencies" CONF_EXCHANGE_PRECISION = "exchange_rate_precision" CONF_EXCHANGE_PRECISION_DEFAULT = 2 -CONF_OPTIONS = "options" CONF_TITLE = "title" DOMAIN = "coinbase" -# These are constants used by the previous YAML configuration -CONF_YAML_API_TOKEN = "api_secret" - # Constants for data returned by Coinbase API API_ACCOUNT_AMOUNT = "amount" API_ACCOUNT_BALANCE = "balance" diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index d1e25dcf2a05b68c4d2329d4458e5310ac637275..e264fed0215591dc4ec3324407b10a9dcb43c6c7 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -5,12 +5,12 @@ import logging from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION 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 CoinbaseData from .const import ( API_ACCOUNT_AMOUNT, API_ACCOUNT_BALANCE, @@ -51,24 +51,24 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Coinbase sensor platform.""" - instance = hass.data[DOMAIN][config_entry.entry_id] + instance: CoinbaseData = hass.data[DOMAIN][config_entry.entry_id] entities: list[SensorEntity] = [] - provided_currencies = [ + provided_currencies: list[str] = [ account[API_ACCOUNT_CURRENCY] for account in instance.accounts if account[API_RESOURCE_TYPE] != API_TYPE_VAULT ] - desired_currencies = [] + desired_currencies: list[str] = [] if CONF_CURRENCIES in config_entry.options: desired_currencies = config_entry.options[CONF_CURRENCIES] - exchange_base_currency = instance.exchange_rates[API_ACCOUNT_CURRENCY] + exchange_base_currency: str = instance.exchange_rates[API_ACCOUNT_CURRENCY] - exchange_precision = config_entry.options.get( + exchange_precision: int = config_entry.options.get( CONF_EXCHANGE_PRECISION, CONF_EXCHANGE_PRECISION_DEFAULT ) @@ -83,6 +83,7 @@ async def async_setup_entry( entities.append(AccountSensor(instance, currency)) if CONF_EXCHANGE_RATES in config_entry.options: + rate: str for rate in config_entry.options[CONF_EXCHANGE_RATES]: entities.append( ExchangeRateSensor( @@ -96,29 +97,36 @@ async def async_setup_entry( class AccountSensor(SensorEntity): """Representation of a Coinbase.com sensor.""" - def __init__(self, coinbase_data, currency): + _attr_attribution = ATTRIBUTION + + def __init__(self, coinbase_data: CoinbaseData, currency: str) -> None: """Initialize the sensor.""" self._coinbase_data = coinbase_data self._currency = currency for account in coinbase_data.accounts: if ( - account[API_ACCOUNT_CURRENCY] == currency - and account[API_RESOURCE_TYPE] != API_TYPE_VAULT + account[API_ACCOUNT_CURRENCY] != currency + or account[API_RESOURCE_TYPE] == API_TYPE_VAULT ): - self._name = f"Coinbase {account[API_ACCOUNT_NAME]}" - self._id = ( - f"coinbase-{account[API_ACCOUNT_ID]}-wallet-" - f"{account[API_ACCOUNT_CURRENCY]}" - ) - self._state = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] - self._unit_of_measurement = account[API_ACCOUNT_CURRENCY] - self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_AMOUNT - ] - self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_CURRENCY - ] - break + continue + self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}" + self._attr_unique_id = ( + f"coinbase-{account[API_ACCOUNT_ID]}-wallet-" + f"{account[API_ACCOUNT_CURRENCY]}" + ) + self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] + self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY] + self._attr_icon = CURRENCY_ICONS.get( + account[API_ACCOUNT_CURRENCY], DEFAULT_COIN_ICON + ) + self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ + API_ACCOUNT_AMOUNT + ] + self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ + API_ACCOUNT_CURRENCY + ] + break + self._attr_state_class = SensorStateClass.TOTAL self._attr_device_info = DeviceInfo( configuration_url="https://www.coinbase.com/settings/api", @@ -129,35 +137,9 @@ class AccountSensor(SensorEntity): ) @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return the Unique ID of the sensor.""" - return self._id - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement this sensor expresses itself in.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return CURRENCY_ICONS.get(self._unit_of_measurement, DEFAULT_COIN_ICON) - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes of the sensor.""" return { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._native_currency}", } @@ -166,34 +148,46 @@ class AccountSensor(SensorEntity): self._coinbase_data.update() for account in self._coinbase_data.accounts: if ( - account[API_ACCOUNT_CURRENCY] == self._currency - and account[API_RESOURCE_TYPE] != API_TYPE_VAULT + account[API_ACCOUNT_CURRENCY] != self._currency + or account[API_RESOURCE_TYPE] == API_TYPE_VAULT ): - self._state = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] - self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_AMOUNT - ] - self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ - API_ACCOUNT_CURRENCY - ] - break + continue + self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] + self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ + API_ACCOUNT_AMOUNT + ] + self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ + API_ACCOUNT_CURRENCY + ] + break class ExchangeRateSensor(SensorEntity): """Representation of a Coinbase.com sensor.""" - def __init__(self, coinbase_data, exchange_currency, exchange_base, precision): + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coinbase_data: CoinbaseData, + exchange_currency: str, + exchange_base: str, + precision: int, + ) -> None: """Initialize the sensor.""" self._coinbase_data = coinbase_data - self.currency = exchange_currency - self._name = f"{exchange_currency} Exchange Rate" - self._id = f"coinbase-{coinbase_data.user_id}-xe-{exchange_currency}" + self._currency = exchange_currency + self._attr_name = f"{exchange_currency} Exchange Rate" + self._attr_unique_id = ( + f"coinbase-{coinbase_data.user_id}-xe-{exchange_currency}" + ) self._precision = precision - self._state = round( - 1 / float(self._coinbase_data.exchange_rates[API_RATES][self.currency]), - self._precision, + self._attr_icon = CURRENCY_ICONS.get(exchange_currency, DEFAULT_COIN_ICON) + self._attr_native_value = round( + 1 / float(coinbase_data.exchange_rates[API_RATES][exchange_currency]), + precision, ) - self._unit_of_measurement = exchange_base + self._attr_native_unit_of_measurement = exchange_base self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_device_info = DeviceInfo( configuration_url="https://www.coinbase.com/settings/api", @@ -203,40 +197,10 @@ class ExchangeRateSensor(SensorEntity): name=f"Coinbase {self._coinbase_data.user_id[-4:]}", ) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID of the sensor.""" - return self._id - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement this sensor expresses itself in.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return CURRENCY_ICONS.get(self.currency, DEFAULT_COIN_ICON) - - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - def update(self) -> None: """Get the latest state of the sensor.""" self._coinbase_data.update() - self._state = round( - 1 / float(self._coinbase_data.exchange_rates.rates[self.currency]), + self._attr_native_value = round( + 1 / float(self._coinbase_data.exchange_rates.rates[self._currency]), self._precision, ) diff --git a/homeassistant/components/coinbase/translations/bg.json b/homeassistant/components/coinbase/translations/bg.json index eb72ab1d10d890bcaa17a17c353ad561c0ae6bf0..cce4b6f5c2a71225c752c2558ce1fa206e174902 100644 --- a/homeassistant/components/coinbase/translations/bg.json +++ b/homeassistant/components/coinbase/translations/bg.json @@ -16,6 +16,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435\u0442\u043e \u043d\u0430 Coinbase \u0441 \u043f\u043e\u043c\u043e\u0449\u0442\u0430 \u043d\u0430 YAML \u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0430\u0442\u043e.\n\n\u0412\u0430\u0448\u0430\u0442\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430 YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0435 \u0441\u0435 \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 \u043e\u0442 Home Assistant.\n\n\u041f\u0440\u0435\u043c\u0430\u0445\u043d\u0435\u0442\u0435 YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043e\u0442 \u0432\u0430\u0448\u0438\u044f \u0444\u0430\u0439\u043b configuration.yaml \u0438 \u0440\u0435\u0441\u0442\u0430\u0440\u0442\u0438\u0440\u0430\u0439\u0442\u0435 Home Assistant, \u0437\u0430 \u0434\u0430 \u043a\u043e\u0440\u0438\u0433\u0438\u0440\u0430\u0442\u0435 \u0442\u043e\u0437\u0438 \u043f\u0440\u043e\u0431\u043b\u0435\u043c.", + "title": "YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 Coinbase \u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0430\u0442\u0430" + } + }, "options": { "error": { "currency_unavailable": "\u0415\u0434\u043d\u043e \u0438\u043b\u0438 \u043f\u043e\u0432\u0435\u0447\u0435 \u043e\u0442 \u0438\u0441\u043a\u0430\u043d\u0438\u0442\u0435 \u0432\u0430\u043b\u0443\u0442\u043d\u0438 \u0441\u0430\u043b\u0434\u0430 \u043d\u0435 \u0441\u0435 \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u044f\u0442 \u043e\u0442 \u0432\u0430\u0448\u0438\u044f Coinbase API.", diff --git a/homeassistant/components/coinbase/translations/ca.json b/homeassistant/components/coinbase/translations/ca.json index 116b611f27266dfd1af9a63fdf658c0e737c7f82..a545f8a278f87f16575bdc8ff91166c089b8f9b3 100644 --- a/homeassistant/components/coinbase/translations/ca.json +++ b/homeassistant/components/coinbase/translations/ca.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "La configuraci\u00f3 de Coinbase mitjan\u00e7ant YAML s'ha eliminat.\n\nHome Assistant ja no utilitza la configuraci\u00f3 YAML existent.\n\nElimina la configuraci\u00f3 YAML corresponent del fitxer configuration.yaml i reinicia Home Assistant per solucionar aquest problema.", + "title": "La configuraci\u00f3 YAML de Coinbase s'ha eliminat" + } + }, "options": { "error": { "currency_unavailable": "L'API de Coinbase no proporciona algun/s dels saldos de moneda que has sol\u00b7licitat.", diff --git a/homeassistant/components/coinbase/translations/de.json b/homeassistant/components/coinbase/translations/de.json index f6200633950eaeea7da769f8a76f5e706620582b..8f91208e58f4bc346b1eb5a0c90a3f27a39f2747 100644 --- a/homeassistant/components/coinbase/translations/de.json +++ b/homeassistant/components/coinbase/translations/de.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Die Konfiguration von Coinbase mit YAML wurde entfernt. \n\nDeine vorhandene YAML-Konfiguration wird von Home Assistant nicht verwendet. \n\nEntferne die YAML-Konfiguration aus deiner configuration.yaml-Datei und starte Home Assistant neu, um dieses Problem zu beheben.", + "title": "Die Coinbase YAML-Konfiguration wurde entfernt" + } + }, "options": { "error": { "currency_unavailable": "Eine oder mehrere der angeforderten W\u00e4hrungssalden werden von deiner Coinbase-API nicht bereitgestellt.", diff --git a/homeassistant/components/coinbase/translations/es.json b/homeassistant/components/coinbase/translations/es.json index 8d89b9b546b5157c64d600540ef43ff47b31d206..d0df4dee96763ef24a7732c0ad5b79da3b8ae112 100644 --- a/homeassistant/components/coinbase/translations/es.json +++ b/homeassistant/components/coinbase/translations/es.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Se ha eliminado la configuraci\u00f3n de Coinbase mediante YAML. \n\nHome Assistant no utiliza tu configuraci\u00f3n YAML existente. \n\nElimina la configuraci\u00f3n YAML de tu archivo configuration.yaml y reinicia Home Assistant para solucionar este problema.", + "title": "Se ha eliminado la configuraci\u00f3n YAML de Coinbase" + } + }, "options": { "error": { "currency_unavailable": "Tu API de Coinbase no proporciona uno o m\u00e1s de los saldos de divisas solicitados.", diff --git a/homeassistant/components/coinbase/translations/et.json b/homeassistant/components/coinbase/translations/et.json index 14bd1eea3703653e69ae72d3ebe63b0a04996341..d91201b3eb089a0bf23d6181d3a5f1e464752243 100644 --- a/homeassistant/components/coinbase/translations/et.json +++ b/homeassistant/components/coinbase/translations/et.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Coinbase'i seadistamine YAML-i abil on eemaldatud.\n\nHome Assistant ei kasuta olemasolevat YAML-i konfiguratsiooni.\n\nEemalda sidumine failist configuration.yaml ja taask\u00e4ivita selle probleemi lahendamiseks Home Assistant.", + "title": "Coinbase YAML konfiguratsioon on eemaldatud" + } + }, "options": { "error": { "currency_unavailable": "Coinbase API ei paku \u00fchte v\u00f5i mitut soovitud valuutasaldot.", diff --git a/homeassistant/components/coinbase/translations/hu.json b/homeassistant/components/coinbase/translations/hu.json index 54122d2996658a0ce71140be4a00adb4f493fe33..3eee97475f3fce5ab889e2179b9b3545b838d8a9 100644 --- a/homeassistant/components/coinbase/translations/hu.json +++ b/homeassistant/components/coinbase/translations/hu.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "A Coinbase YAML haszn\u00e1lat\u00e1val t\u00f6rt\u00e9n\u0151 konfigur\u00e1l\u00e1sa elt\u00e1vol\u00edt\u00e1sra ker\u00fclt.\n\nA megl\u00e9v\u0151 YAML konfigur\u00e1ci\u00f3j\u00e1t a Home Assistant nem haszn\u00e1lja.\n\nA probl\u00e9ma megold\u00e1s\u00e1hoz t\u00e1vol\u00edtsa el a YAML konfigur\u00e1ci\u00f3t a configuration.yaml f\u00e1jlb\u00f3l, \u00e9s ind\u00edtsa \u00fajra a Home Assistantot.", + "title": "A Coinbase YAML konfigur\u00e1ci\u00f3 elt\u00e1vol\u00edt\u00e1sra ker\u00fclt" + } + }, "options": { "error": { "currency_unavailable": "A k\u00e9rt valutaegyenlegek k\u00f6z\u00fcl egyet vagy t\u00f6bbet nem biztos\u00edt a Coinbase API.", diff --git a/homeassistant/components/coinbase/translations/id.json b/homeassistant/components/coinbase/translations/id.json index 114c69acce256389347d0482aa93f13e958ed598..7cb9c5c499288cdc013809e3599416d6d3e6630b 100644 --- a/homeassistant/components/coinbase/translations/id.json +++ b/homeassistant/components/coinbase/translations/id.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Proses konfigurasi Integrasi Coinbase lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Coinbase telah dihapus" + } + }, "options": { "error": { "currency_unavailable": "Satu atau beberapa saldo mata uang yang diminta tidak disediakan oleh API Coinbase Anda.", diff --git a/homeassistant/components/coinbase/translations/it.json b/homeassistant/components/coinbase/translations/it.json index f26e08a727c4dad1bbb1b55f8bb85a1ff3e82586..06507e2e71c386aac1d3f5d84b1ffa52dff63f9f 100644 --- a/homeassistant/components/coinbase/translations/it.json +++ b/homeassistant/components/coinbase/translations/it.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "La configurazione di Coinbase tramite YAML \u00e8 stata rimossa. \n\nLa tua configurazione YAML esistente non \u00e8 utilizzata da Home Assistant. \n\nRimuovi la configurazione YAML dal file configuration.yaml e riavvia Home Assistant per risolvere questo problema.", + "title": "La configurazione YAML di Coinbase \u00e8 stata rimossa" + } + }, "options": { "error": { "currency_unavailable": "Uno o pi\u00f9 dei saldi in valuta richiesti non sono forniti dalla tua API Coinbase.", diff --git a/homeassistant/components/coinbase/translations/nb.json b/homeassistant/components/coinbase/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..4209449f49b5f780329f9c916adb3dc554f89fc5 --- /dev/null +++ b/homeassistant/components/coinbase/translations/nb.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + }, + "issues": { + "removed_yaml": { + "description": "Konfigurering av Coinbase med YAML er fjernet.\n\nDin eksisterende YAML-konfigurasjon brukes ikke av Home Assistant.\n\nFjern YAML-konfigurasjonen fra configuration.yaml-filen og start Home Assistant p\u00e5 nytt for \u00e5 l\u00f8se dette problemet." + } + }, + "options": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/nl.json b/homeassistant/components/coinbase/translations/nl.json index 472a15659c067b8ea2d2ef1fc4bde54b6b729087..5c47abfebbd00485a7cc33b45cbab67c9092a4a6 100644 --- a/homeassistant/components/coinbase/translations/nl.json +++ b/homeassistant/components/coinbase/translations/nl.json @@ -21,6 +21,11 @@ } } }, + "issues": { + "removed_yaml": { + "title": "De Coinbase YAML-configuratie is verwijderd" + } + }, "options": { "error": { "currency_unavailable": "Een of meer van de gevraagde valutabalansen wordt niet geleverd door uw Coinbase API.", diff --git a/homeassistant/components/coinbase/translations/no.json b/homeassistant/components/coinbase/translations/no.json index c3f2b34cf92fb4bc219fe1e1ad13f2c0b64c7050..1cac2a2e741ede1376a708e3ce2667e8fc57ef3a 100644 --- a/homeassistant/components/coinbase/translations/no.json +++ b/homeassistant/components/coinbase/translations/no.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Konfigurering av Coinbase med YAML er fjernet.\n\nDin eksisterende YAML-konfigurasjon brukes ikke av Home Assistant.\n\nFjern YAML-konfigurasjonen fra configuration.yaml-filen og start Home Assistant p\u00e5 nytt for \u00e5 l\u00f8se dette problemet.", + "title": "Coinbase YAML-konfigurasjonen er fjernet" + } + }, "options": { "error": { "currency_unavailable": "En eller flere av de forespurte valutasaldoene leveres ikke av Coinbase API.", diff --git a/homeassistant/components/coinbase/translations/pl.json b/homeassistant/components/coinbase/translations/pl.json index 70a1a021cdfd91414bc800f5941e13daf109ba2f..3b81c0f7aca1022d2626c7185968f609b39269af 100644 --- a/homeassistant/components/coinbase/translations/pl.json +++ b/homeassistant/components/coinbase/translations/pl.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Konfiguracja Coinbase za pomoc\u0105 YAML zosta\u0142a usuni\u0119ta. \n\nTwoja istniej\u0105ca konfiguracja YAML nie jest u\u017cywana przez Home Assistant. \n\nUsu\u0144 konfiguracj\u0119 YAML z pliku configuration.yaml i uruchom ponownie Home Assistant, aby rozwi\u0105za\u0107 ten problem.", + "title": "Konfiguracja YAML dla Coinbase zosta\u0142a usuni\u0119ta" + } + }, "options": { "error": { "currency_unavailable": "Jeden lub wi\u0119cej \u017c\u0105danych sald walutowych nie jest dostarczanych przez interfejs API Coinbase.", diff --git a/homeassistant/components/coinbase/translations/pt-BR.json b/homeassistant/components/coinbase/translations/pt-BR.json index 5f2bb7d96e39614f073615ce8664a2b85f77ff94..6a8afe42dbacf0f7cb57fe6614f800eb6ef0ca00 100644 --- a/homeassistant/components/coinbase/translations/pt-BR.json +++ b/homeassistant/components/coinbase/translations/pt-BR.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "A configura\u00e7\u00e3o do Coinbase usando YAML foi removida. \n\n Sua configura\u00e7\u00e3o YAML existente n\u00e3o \u00e9 usada pelo Home Assistant. \n\n Remova a configura\u00e7\u00e3o YAML do arquivo configuration.yaml e reinicie o Home Assistant para corrigir esse problema.", + "title": "A configura\u00e7\u00e3o YAML do Coinbase foi removida" + } + }, "options": { "error": { "currency_unavailable": "Um ou mais dos saldos de moeda solicitados n\u00e3o s\u00e3o fornecidos pela sua API Coinbase.", diff --git a/homeassistant/components/coinbase/translations/ru.json b/homeassistant/components/coinbase/translations/ru.json index cbdf39e61a6e0f65fef3144ab6393dea1e96b7e2..85ec5646749e88a11c7ef1dd29d6a815ae086d74 100644 --- a/homeassistant/components/coinbase/translations/ru.json +++ b/homeassistant/components/coinbase/translations/ru.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \"Coinbase\" \u0442\u0435\u043f\u0435\u0440\u044c \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0435\u0440\u0435\u0437 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0439 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441.\n\n\u0412\u0430\u0448\u0430 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0430\u044f \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f YAML \u043d\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f Home Assistant.\n\n\u0423\u0434\u0430\u043b\u0438\u0442\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u044e\u0449\u0443\u044e \u0447\u0430\u0441\u0442\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0438\u0437 \u0444\u0430\u0439\u043b\u0430 configuration.yaml \u0438 \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u0435 Home Assistant, \u0447\u0442\u043e\u0431\u044b \u0443\u0441\u0442\u0440\u0430\u043d\u0438\u0442\u044c \u044d\u0442\u0443 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443.", + "title": "\u0423\u0434\u0430\u043b\u0435\u043d\u0430 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Coinbase \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e YAML" + } + }, "options": { "error": { "currency_unavailable": "\u041e\u0434\u0438\u043d \u0438\u043b\u0438 \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u0440\u043e\u0448\u0435\u043d\u043d\u044b\u0445 \u043e\u0441\u0442\u0430\u0442\u043a\u043e\u0432 \u0432\u0430\u043b\u044e\u0442\u044b \u043d\u0435 \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u044e\u0442\u0441\u044f \u0412\u0430\u0448\u0438\u043c API Coinbase.", diff --git a/homeassistant/components/coinbase/translations/tr.json b/homeassistant/components/coinbase/translations/tr.json index b84e2bf740ec0258c7eebea2ed5f8df1160af024..ca91c29200e1e105ea81b147aecffe9a30da9acc 100644 --- a/homeassistant/components/coinbase/translations/tr.json +++ b/homeassistant/components/coinbase/translations/tr.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Coinbase'i YAML kullanarak yap\u0131land\u0131rma kald\u0131r\u0131ld\u0131. \n\n Mevcut YAML yap\u0131land\u0131rman\u0131z Home Assistant taraf\u0131ndan kullan\u0131lm\u0131yor. \n\n YAML yap\u0131land\u0131rmas\u0131n\u0131 configuration.yaml dosyan\u0131zdan kald\u0131r\u0131n ve bu sorunu gidermek i\u00e7in Home Assistant'\u0131 yeniden ba\u015flat\u0131n.", + "title": "Coinbase YAML yap\u0131land\u0131rmas\u0131 kald\u0131r\u0131ld\u0131" + } + }, "options": { "error": { "currency_unavailable": "\u0130stenen para birimi bakiyelerinden biri veya daha fazlas\u0131 Coinbase API'niz taraf\u0131ndan sa\u011flanm\u0131yor.", diff --git a/homeassistant/components/coinbase/translations/zh-Hant.json b/homeassistant/components/coinbase/translations/zh-Hant.json index ea48d90fc7e0962d06bbbd7402d666b711d3836c..2b73d75d9bc948b18ba44e338a1d55bc7c2ea20f 100644 --- a/homeassistant/components/coinbase/translations/zh-Hant.json +++ b/homeassistant/components/coinbase/translations/zh-Hant.json @@ -21,6 +21,12 @@ } } }, + "issues": { + "removed_yaml": { + "description": "\u4f7f\u7528 YAML \u8a2d\u5b9a Coinbase \u7684\u529f\u80fd\u5373\u5c07\u79fb\u9664\u3002\n\nHome Assistant \u5c07\u4e0d\u518d\u4f7f\u7528\u73fe\u6709\u7684 YAML \u8a2d\u5b9a\u3002\n\n\u7531 configuration.yaml \u6a94\u6848\u4e2d\u79fb\u9664 YAML \u8a2d\u5b9a\u4e26\u91cd\u555f Home Assistant \u4ee5\u4fee\u6b63\u6b64\u554f\u984c\u3002", + "title": "Coinbase YAML \u8a2d\u5b9a\u5373\u5c07\u79fb\u9664" + } + }, "options": { "error": { "currency_unavailable": "Coinbase API \u672a\u63d0\u4f9b\u4e00\u500b\u6216\u591a\u500b\u6240\u8981\u6c42\u7684\u8ca8\u5e63\u9918\u984d\u3002", diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index 38421813439c7aa1f4c73d50c4b7b17b5c9482e9..0e26b3406b8e9e191df5127e944fe5dfd40e678e 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET +from homeassistant.const import CONF_NAME, CONF_OFFSET from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -27,8 +27,6 @@ _RESOURCE = "https://hourlypricing.comed.com/api" SCAN_INTERVAL = timedelta(minutes=5) -ATTRIBUTION = "Data provided by ComEd Hourly Pricing service" - CONF_CURRENT_HOUR_AVERAGE = "current_hour_average" CONF_FIVE_MINUTE = "five_minute" CONF_MONITORED_FEEDS = "monitored_feeds" @@ -91,7 +89,7 @@ async def async_setup_platform( class ComedHourlyPricingSensor(SensorEntity): """Implementation of a ComEd Hourly Pricing sensor.""" - _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + _attr_attribution = "Data provided by ComEd Hourly Pricing service" def __init__(self, websession, offset, name, description: SensorEntityDescription): """Initialize the sensor.""" diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index dd2c9632d7cd015c5a0342f9fd01dadd6416e4db..5341a5f69254cd46568c878e20fd0a82290e739a 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -10,7 +10,10 @@ from pycomfoconnect import ( CMD_FAN_MODE_HIGH, CMD_FAN_MODE_LOW, CMD_FAN_MODE_MEDIUM, + CMD_MODE_AUTO, + CMD_MODE_MANUAL, SENSOR_FAN_SPEED_MODE, + SENSOR_OPERATING_MODE_BIS, ) from homeassistant.components.fan import FanEntity, FanEntityFeature @@ -37,6 +40,9 @@ CMD_MAPPING = { SPEED_RANGE = (1, 3) # away is not included in speeds and instead mapped to off +PRESET_MODE_AUTO = "auto" +PRESET_MODES = [PRESET_MODE_AUTO] + def setup_platform( hass: HomeAssistant, @@ -55,7 +61,8 @@ class ComfoConnectFan(FanEntity): _attr_icon = "mdi:air-conditioner" _attr_should_poll = False - _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + _attr_preset_modes = PRESET_MODES current_speed = None def __init__(self, ccb: ComfoConnectBridge) -> None: @@ -63,6 +70,7 @@ class ComfoConnectFan(FanEntity): self._ccb = ccb self._attr_name = ccb.name self._attr_unique_id = ccb.unique_id + self._attr_preset_mode = None async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -71,14 +79,24 @@ class ComfoConnectFan(FanEntity): async_dispatcher_connect( self.hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(SENSOR_FAN_SPEED_MODE), - self._handle_update, + self._handle_speed_update, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(SENSOR_OPERATING_MODE_BIS), + self._handle_mode_update, ) ) await self.hass.async_add_executor_job( self._ccb.comfoconnect.register_sensor, SENSOR_FAN_SPEED_MODE ) + await self.hass.async_add_executor_job( + self._ccb.comfoconnect.register_sensor, SENSOR_OPERATING_MODE_BIS + ) - def _handle_update(self, value: float) -> None: + def _handle_speed_update(self, value: float) -> None: """Handle update callbacks.""" _LOGGER.debug( "Handle update for fan speed (%d): %s", SENSOR_FAN_SPEED_MODE, value @@ -86,6 +104,16 @@ class ComfoConnectFan(FanEntity): self.current_speed = value self.schedule_update_ha_state() + def _handle_mode_update(self, value: int) -> None: + """Handle update callbacks.""" + _LOGGER.debug( + "Handle update for operating mode (%d): %s", + SENSOR_OPERATING_MODE_BIS, + value, + ) + self._attr_preset_mode = PRESET_MODE_AUTO if value == -1 else None + self.schedule_update_ha_state() + @property def percentage(self) -> int | None: """Return the current speed percentage.""" @@ -105,6 +133,10 @@ class ComfoConnectFan(FanEntity): **kwargs: Any, ) -> None: """Turn on the fan.""" + if preset_mode: + self.set_preset_mode(preset_mode) + return + if percentage is None: self.set_percentage(1) # Set fan speed to low else: @@ -125,3 +157,15 @@ class ComfoConnectFan(FanEntity): cmd = CMD_MAPPING[speed] self._ccb.comfoconnect.cmd_rmi_request(cmd) + + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if not self.preset_modes or preset_mode not in self.preset_modes: + raise ValueError(f"Invalid preset mode: {preset_mode}") + + _LOGGER.debug("Changing preset mode to %s", preset_mode) + if preset_mode == PRESET_MODE_AUTO: + # force set it to manual first + self._ccb.comfoconnect.cmd_rmi_request(CMD_MODE_MANUAL) + # now set it to auto so any previous percentage set gets undone + self._ccb.comfoconnect.cmd_rmi_request(CMD_MODE_AUTO) diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index 3a2c55b3f5d0a2409a88e3d8bb76ecf54a531d19..bf63a516b58b000795cb59fdad230177baaea17e 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -1,8 +1,10 @@ """HTTP views to interact with the area registry.""" +from typing import Any + import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.area_registry import async_get @@ -17,7 +19,11 @@ async def async_setup(hass): @websocket_api.websocket_command({vol.Required("type"): "config/area_registry/list"}) @callback -def websocket_list_areas(hass, connection, msg): +def websocket_list_areas( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle list areas command.""" registry = async_get(hass) connection.send_result( @@ -35,7 +41,11 @@ def websocket_list_areas(hass, connection, msg): ) @websocket_api.require_admin @callback -def websocket_create_area(hass, connection, msg): +def websocket_create_area( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Create area command.""" registry = async_get(hass) @@ -59,7 +69,11 @@ def websocket_create_area(hass, connection, msg): ) @websocket_api.require_admin @callback -def websocket_delete_area(hass, connection, msg): +def websocket_delete_area( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Delete area command.""" registry = async_get(hass) @@ -81,7 +95,11 @@ def websocket_delete_area(hass, connection, msg): ) @websocket_api.require_admin @callback -def websocket_update_area(hass, connection, msg): +def websocket_update_area( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle update area websocket command.""" registry = async_get(hass) diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 15fc6634f5b2965c0f3e428b8ac0b7012cf3b985..1699a4c850983a3bfebd6afa789604992499a7f7 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -1,7 +1,10 @@ """Offer API to configure Home Assistant auth.""" +from typing import Any + import voluptuous as vol from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant WS_TYPE_LIST = "config/auth/list" SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( @@ -29,7 +32,11 @@ async def async_setup(hass): @websocket_api.require_admin @websocket_api.async_response -async def websocket_list(hass, connection, msg): +async def websocket_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Return a list of users.""" result = [_user_info(u) for u in await hass.auth.async_get_users()] @@ -38,7 +45,11 @@ async def websocket_list(hass, connection, msg): @websocket_api.require_admin @websocket_api.async_response -async def websocket_delete(hass, connection, msg): +async def websocket_delete( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Delete a user.""" if msg["user_id"] == connection.user.id: connection.send_message( @@ -69,7 +80,11 @@ async def websocket_delete(hass, connection, msg): } ) @websocket_api.async_response -async def websocket_create(hass, connection, msg): +async def websocket_create( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Create a user.""" user = await hass.auth.async_create_user( msg["name"], group_ids=msg.get("group_ids"), local_only=msg.get("local_only") @@ -92,7 +107,11 @@ async def websocket_create(hass, connection, msg): } ) @websocket_api.async_response -async def websocket_update(hass, connection, msg): +async def websocket_update( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Update a user.""" if not (user := await hass.auth.async_get_user(msg.pop("user_id"))): connection.send_message( diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py index b6874720c47bb6498215effcb82e9277e0dc334a..d0606a748a9026627f066630cea0793b71039cd3 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -1,9 +1,11 @@ """Offer API to configure the Home Assistant auth provider.""" +from typing import Any + import voluptuous as vol from homeassistant.auth.providers import homeassistant as auth_ha from homeassistant.components import websocket_api -from homeassistant.components.websocket_api import decorators +from homeassistant.core import HomeAssistant from homeassistant.exceptions import Unauthorized @@ -16,7 +18,7 @@ async def async_setup(hass): return True -@decorators.websocket_command( +@websocket_api.websocket_command( { vol.Required("type"): "config/auth_provider/homeassistant/create", vol.Required("user_id"): str, @@ -26,7 +28,11 @@ async def async_setup(hass): ) @websocket_api.require_admin @websocket_api.async_response -async def websocket_create(hass, connection, msg): +async def websocket_create( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Create credentials and attach to a user.""" provider = auth_ha.async_get_provider(hass) @@ -56,7 +62,7 @@ async def websocket_create(hass, connection, msg): connection.send_result(msg["id"]) -@decorators.websocket_command( +@websocket_api.websocket_command( { vol.Required("type"): "config/auth_provider/homeassistant/delete", vol.Required("username"): str, @@ -64,7 +70,11 @@ async def websocket_create(hass, connection, msg): ) @websocket_api.require_admin @websocket_api.async_response -async def websocket_delete(hass, connection, msg): +async def websocket_delete( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Delete username and related credential.""" provider = auth_ha.async_get_provider(hass) credentials = await provider.async_get_or_create_credentials( @@ -90,7 +100,7 @@ async def websocket_delete(hass, connection, msg): connection.send_result(msg["id"]) -@decorators.websocket_command( +@websocket_api.websocket_command( { vol.Required("type"): "config/auth_provider/homeassistant/change_password", vol.Required("current_password"): str, @@ -98,7 +108,11 @@ async def websocket_delete(hass, connection, msg): } ) @websocket_api.async_response -async def websocket_change_password(hass, connection, msg): +async def websocket_change_password( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Change current user password.""" if (user := connection.user) is None: connection.send_error(msg["id"], "user_not_found", "User not found") @@ -130,7 +144,7 @@ async def websocket_change_password(hass, connection, msg): connection.send_result(msg["id"]) -@decorators.websocket_command( +@websocket_api.websocket_command( { vol.Required( "type" @@ -139,9 +153,13 @@ async def websocket_change_password(hass, connection, msg): vol.Required("password"): str, } ) -@decorators.require_admin -@decorators.async_response -async def websocket_admin_change_password(hass, connection, msg): +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_admin_change_password( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Change password of any user.""" if not connection.user.is_owner: raise Unauthorized(context=connection.context(msg)) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index fb96a99827d6e4b91ebfc138566bf11993fa6b0d..39c5bce25cb40c151f5a03a2d05ace213e8fb6c6 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -13,9 +13,9 @@ 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 +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView, @@ -65,7 +65,7 @@ class ConfigManagerEntryIndexView(HomeAssistantView): domain = request.query["domain"] type_filter = None if "type" in request.query: - type_filter = request.query["type"] + type_filter = [request.query["type"]] return self.json(await async_matching_config_entries(hass, type_filter, domain)) @@ -243,7 +243,11 @@ class OptionManagerFlowResourceView(FlowManagerResourceView): @websocket_api.require_admin @websocket_api.websocket_command({"type": "config_entries/flow/progress"}) -def config_entries_progress(hass, connection, msg): +def config_entries_progress( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """List flows that are in progress but not started by a user. Example of a non-user initiated flow is a discovered Hue hub that @@ -291,7 +295,11 @@ def get_entry( } ) @websocket_api.async_response -async def config_entry_update(hass, connection, msg): +async def config_entry_update( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Update config entry.""" changes = dict(msg) changes.pop("id") @@ -311,9 +319,10 @@ async def config_entry_update(hass, connection, msg): "require_restart": False, } + initial_state = entry.state if ( old_disable_polling != entry.pref_disable_polling - and entry.state is config_entries.ConfigEntryState.LOADED + and initial_state is config_entries.ConfigEntryState.LOADED ): if not await hass.config_entries.async_reload(entry.entry_id): result["require_restart"] = ( @@ -334,14 +343,18 @@ async def config_entry_update(hass, connection, msg): } ) @websocket_api.async_response -async def config_entry_disable(hass, connection, msg): +async def config_entry_disable( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Disable config entry.""" if (disabled_by := msg["disabled_by"]) is not None: disabled_by = config_entries.ConfigEntryDisabler(disabled_by) - result = False + success = False try: - result = await hass.config_entries.async_set_disabled_by( + success = await hass.config_entries.async_set_disabled_by( msg["entry_id"], disabled_by ) except config_entries.OperationNotAllowed: @@ -351,7 +364,7 @@ async def config_entry_disable(hass, connection, msg): send_entry_not_found(connection, msg["id"]) return - result = {"require_restart": not result} + result = {"require_restart": not success} connection.send_result(msg["id"], result) @@ -361,7 +374,11 @@ async def config_entry_disable(hass, connection, msg): {"type": "config_entries/ignore_flow", "flow_id": str, "title": str} ) @websocket_api.async_response -async def ignore_config_flow(hass, connection, msg): +async def ignore_config_flow( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Ignore a config flow.""" flow = next( ( @@ -393,13 +410,15 @@ async def ignore_config_flow(hass, connection, msg): @websocket_api.websocket_command( { vol.Required("type"): "config_entries/get", - vol.Optional("type_filter"): str, + vol.Optional("type_filter"): vol.All(cv.ensure_list, [str]), vol.Optional("domain"): str, } ) @websocket_api.async_response async def config_entries_get( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], ) -> None: """Return matching config entries by type and/or domain.""" connection.send_result( @@ -413,12 +432,14 @@ async def config_entries_get( @websocket_api.websocket_command( { vol.Required("type"): "config_entries/subscribe", - vol.Optional("type_filter"): str, + vol.Optional("type_filter"): vol.All(cv.ensure_list, [str]), } ) @websocket_api.async_response async def config_entries_subscribe( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], ) -> None: """Subscribe to config entry updates.""" type_filter = msg.get("type_filter") @@ -429,7 +450,7 @@ async def config_entries_subscribe( """Forward config entry state events to websocket.""" if type_filter: integration = await async_get_integration(hass, entry.domain) - if integration.integration_type != type_filter: + if integration.integration_type not in type_filter: return connection.send_message( @@ -459,7 +480,7 @@ async def config_entries_subscribe( async def async_matching_config_entries( - hass: HomeAssistant, type_filter: str | None, domain: str | None + hass: HomeAssistant, type_filter: list[str] | None, domain: str | None ) -> list[dict[str, Any]]: """Return matching config entries by type and/or domain.""" kwargs = {} @@ -467,7 +488,7 @@ async def async_matching_config_entries( kwargs["domain"] = domain entries = hass.config_entries.async_entries(**kwargs) - if type_filter is None: + if not type_filter: return [entry_json(entry) for entry in entries] integrations = {} @@ -483,13 +504,17 @@ async def async_matching_config_entries( elif not isinstance(integration_or_exc, IntegrationNotFound): raise integration_or_exc + # Filter out entries that don't match the type filter + # when only helpers are requested, also filter out entries + # from unknown integrations. This prevent them from showing + # up in the helpers UI. entries = [ entry for entry in entries - if (type_filter != "helper" and entry.domain not in integrations) + if (type_filter != ["helper"] and entry.domain not in integrations) or ( entry.domain in integrations - and integrations[entry.domain].integration_type == type_filter + and integrations[entry.domain].integration_type in type_filter ) ] diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 3f665e475f070d48d547fbe4919dc3a30a3b689c..c748395e95f7137edf2e4c8dc842720543476235 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -1,14 +1,16 @@ """Component to interact with Hassbian tools.""" +from typing import Any + import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView from homeassistant.config import async_check_ha_config_file -from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import location +from homeassistant.util import location, unit_system async def async_setup(hass): @@ -41,7 +43,7 @@ class CheckConfigView(HomeAssistantView): vol.Optional("latitude"): cv.latitude, vol.Optional("longitude"): cv.longitude, vol.Optional("elevation"): int, - vol.Optional("unit_system"): cv.unit_system, + vol.Optional("unit_system"): unit_system.validate_unit_system, vol.Optional("location_name"): str, vol.Optional("time_zone"): cv.time_zone, vol.Optional("external_url"): vol.Any(cv.url_no_path, None), @@ -50,7 +52,11 @@ class CheckConfigView(HomeAssistantView): } ) @websocket_api.async_response -async def websocket_update_config(hass, connection, msg): +async def websocket_update_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle update core config command.""" data = dict(msg) data.pop("id") @@ -66,21 +72,29 @@ async def websocket_update_config(hass, connection, msg): @websocket_api.require_admin @websocket_api.websocket_command({"type": "config/core/detect"}) @websocket_api.async_response -async def websocket_detect_config(hass, connection, msg): +async def websocket_detect_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Detect core config.""" session = async_get_clientsession(hass) location_info = await location.async_detect_location_info(session) - info = {} + info: dict[str, Any] = {} if location_info is None: connection.send_result(msg["id"], info) return + # We don't want any integrations to use the name of the unit system + # so we are using the private attribute here if location_info.use_metric: - info["unit_system"] = CONF_UNIT_SYSTEM_METRIC + # pylint: disable-next=protected-access + info["unit_system"] = unit_system._CONF_UNIT_SYSTEM_METRIC else: - info["unit_system"] = CONF_UNIT_SYSTEM_IMPERIAL + # pylint: disable-next=protected-access + info["unit_system"] = unit_system._CONF_UNIT_SYSTEM_US_CUSTOMARY if location_info.latitude: info["latitude"] = location_info.latitude diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 8edd9f1f4d392b531776919e332add7023804c8b..6287d5863434821a11caeb3848ffe814c0079412 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -1,6 +1,8 @@ """HTTP views to interact with the device registry.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant import loader @@ -109,7 +111,9 @@ def websocket_update_device(hass, connection, msg): ) @websocket_api.async_response async def websocket_remove_config_entry_from_device( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], ) -> None: """Remove config entry from a device.""" registry = async_get(hass) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index b024c3f0128fe956523992a343756750b91b6419..b4bd7403c43c5012fe051ce70a664a7317d9ce97 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -36,14 +36,18 @@ async def async_setup(hass: HomeAssistant) -> bool: {vol.Required("type"): "config/entity_registry/list"} ) @callback - def websocket_list_entities(hass, connection, msg): + def websocket_list_entities( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: """Handle list registry entries command.""" nonlocal cached_list_entities if not cached_list_entities: registry = er.async_get(hass) cached_list_entities = message_to_json( websocket_api.result_message( - IDEN_TEMPLATE, + IDEN_TEMPLATE, # type: ignore[arg-type] [_entry_dict(entry) for entry in registry.entities.values()], ) ) @@ -70,7 +74,11 @@ async def async_setup(hass: HomeAssistant) -> bool: } ) @callback -def websocket_get_entity(hass, connection, msg): +def websocket_get_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle get entity registry entry command. Async friendly. @@ -120,7 +128,11 @@ def websocket_get_entity(hass, connection, msg): } ) @callback -def websocket_update_entity(hass, connection, msg): +def websocket_update_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle update entity websocket command. Async friendly. @@ -153,7 +165,7 @@ def websocket_update_entity(hass, connection, msg): if entity_entry.device_id: device_registry = dr.async_get(hass) device = device_registry.async_get(entity_entry.device_id) - if device.disabled: + if device and device.disabled: connection.send_message( websocket_api.error_message( msg["id"], "invalid_info", "Device is disabled" @@ -184,11 +196,14 @@ def websocket_update_entity(hass, connection, msg): ) return - result = {"entity_entry": _entry_ext_dict(entity_entry)} + result: dict[str, Any] = {"entity_entry": _entry_ext_dict(entity_entry)} if "disabled_by" in changes and changes["disabled_by"] is None: # Enabling an entity requires a config entry reload, or HA restart - config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id) - if config_entry and not config_entry.supports_unload: + if ( + not (config_entry_id := entity_entry.config_entry_id) + or (config_entry := hass.config_entries.async_get_entry(config_entry_id)) + and not config_entry.supports_unload + ): result["require_restart"] = True else: result["reload_delay"] = config_entries.RELOAD_AFTER_UPDATE_DELAY @@ -203,7 +218,11 @@ def websocket_update_entity(hass, connection, msg): } ) @callback -def websocket_remove_entity(hass, connection, msg): +def websocket_remove_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle remove entity websocket command. Async friendly. diff --git a/homeassistant/components/control4/translations/nb.json b/homeassistant/components/control4/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/control4/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 9fd6d1ad3e2df43c67473179979bac69b0ac24bb..deab740909e0b1877a7df93f866fbc0ac59a4224 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from http import HTTPStatus import logging import re +from typing import Any import voluptuous as vol @@ -84,7 +85,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: {"type": "conversation/process", "text": str, vol.Optional("conversation_id"): str} ) @websocket_api.async_response -async def websocket_process(hass, connection, msg): +async def websocket_process( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Process text.""" connection.send_result( msg["id"], @@ -96,7 +101,11 @@ async def websocket_process(hass, connection, msg): @websocket_api.websocket_command({"type": "conversation/agent/info"}) @websocket_api.async_response -async def websocket_get_agent_info(hass, connection, msg): +async def websocket_get_agent_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Do we need onboarding.""" agent = await _get_agent(hass) @@ -111,7 +120,11 @@ async def websocket_get_agent_info(hass, connection, msg): @websocket_api.websocket_command({"type": "conversation/onboarding/set", "shown": bool}) @websocket_api.async_response -async def websocket_set_onboarding(hass, connection, msg): +async def websocket_set_onboarding( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Set onboarding status.""" agent = await _get_agent(hass) @@ -120,7 +133,7 @@ async def websocket_set_onboarding(hass, connection, msg): if success: connection.send_result(msg["id"]) else: - connection.send_error(msg["id"]) + connection.send_error(msg["id"], "error", "Failed to set onboarding") class ConversationProcessView(http.HomeAssistantView): @@ -165,7 +178,10 @@ async def _get_agent(hass: core.HomeAssistant) -> AbstractConversationAgent: async def _async_converse( - hass: core.HomeAssistant, text: str, conversation_id: str, context: core.Context + hass: core.HomeAssistant, + text: str, + conversation_id: str | None, + context: core.Context, ) -> intent.IntentResponse: """Process text and get intent.""" agent = await _get_agent(hass) diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py index 1a4f5b72fcd1bdf855b50de25f483102baac01a7..7fa7c5aed087e65d11cfce88e93498cbcb237e4d 100644 --- a/homeassistant/components/coronavirus/sensor.py +++ b/homeassistant/components/coronavirus/sensor.py @@ -1,7 +1,6 @@ """Sensor platform for the Corona virus.""" from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -34,12 +33,12 @@ async def async_setup_entry( class CoronavirusSensor(CoordinatorEntity, SensorEntity): """Sensor representing corona virus data.""" + _attr_attribution = ATTRIBUTION _attr_native_unit_of_measurement = "people" def __init__(self, coordinator, country, info_type): """Initialize coronavirus sensor.""" super().__init__(coordinator) - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} self._attr_icon = SENSORS[info_type] self._attr_unique_id = f"{country}-{info_type}" if country == OPTION_WORLDWIDE: diff --git a/homeassistant/components/coronavirus/translations/bg.json b/homeassistant/components/coronavirus/translations/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..c30e629d8addc6fef89984c01d1aba172e7d6402 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/bg.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/is.json b/homeassistant/components/cover/translations/is.json index 4a61c4f7cc5485f2e0a754e080a994dd12b31caa..ce8052eb02b712ff117336cd624068b579a49c05 100644 --- a/homeassistant/components/cover/translations/is.json +++ b/homeassistant/components/cover/translations/is.json @@ -1,4 +1,13 @@ { + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} er loku\u00f0" + }, + "trigger_type": { + "closed": "{entity_name} loku\u00f0", + "opened": "{entity_name} opnu\u00f0" + } + }, "state": { "_": { "closed": "Loka\u00f0", diff --git a/homeassistant/components/cozytouch/manifest.json b/homeassistant/components/cozytouch/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..83b76211be0f60a3e69b5aa0d0716e7c9f19df4d --- /dev/null +++ b/homeassistant/components/cozytouch/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "cozytouch", + "name": "Atlantic Cozytouch", + "integration_type": "virtual", + "supported_by": "overkiz" +} diff --git a/homeassistant/components/cpuspeed/manifest.json b/homeassistant/components/cpuspeed/manifest.json index 34628b99341f9d625dce5e1ace8a420c8083be52..06a331d6d87a835f46df0b431d389e5fa08c6825 100644 --- a/homeassistant/components/cpuspeed/manifest.json +++ b/homeassistant/components/cpuspeed/manifest.json @@ -5,5 +5,6 @@ "requirements": ["py-cpuinfo==8.0.0"], "config_flow": true, "codeowners": ["@fabaff", "@frenck"], - "iot_class": "local_push" + "iot_class": "local_push", + "integration_type": "device" } diff --git a/homeassistant/components/crownstone/translations/bg.json b/homeassistant/components/crownstone/translations/bg.json index 2c567e2a1e8e341917a9c6db7bb5588a8e12305b..94752b315bbdaf13285c05c010b679cf42c0b7dd 100644 --- a/homeassistant/components/crownstone/translations/bg.json +++ b/homeassistant/components/crownstone/translations/bg.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "email": "Email", + "email": "\u0418\u043c\u0435\u0439\u043b", "password": "\u041f\u0430\u0440\u043e\u043b\u0430" } } diff --git a/homeassistant/components/crownstone/translations/nb.json b/homeassistant/components/crownstone/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/crownstone/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index 85f8b8767654e6c9ce23b8d525349ec185af6702..9905228c26ae6bb38cd3ed834f5d876413e1759d 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -8,13 +8,7 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_API_KEY, - CONF_BASE, - CONF_NAME, - CONF_QUOTE, -) +from homeassistant.const import CONF_API_KEY, CONF_BASE, CONF_NAME, CONF_QUOTE from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,8 +17,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) _RESOURCE = "http://apilayer.net/api/live" -ATTRIBUTION = "Data provided by currencylayer.com" - DEFAULT_BASE = "USD" DEFAULT_NAME = "CurrencyLayer Sensor" @@ -67,6 +59,8 @@ def setup_platform( class CurrencylayerSensor(SensorEntity): """Implementing the Currencylayer sensor.""" + _attr_attribution = "Data provided by currencylayer.com" + def __init__(self, rest, base, quote): """Initialize the sensor.""" self.rest = rest @@ -94,11 +88,6 @@ class CurrencylayerSensor(SensorEntity): """Return the state of the sensor.""" return self._state - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - def update(self) -> None: """Update current date.""" self.rest.update() diff --git a/homeassistant/components/dacia/manifest.json b/homeassistant/components/dacia/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..d637d7cc1e4e1b066adcb2f3393865d16a56cb9f --- /dev/null +++ b/homeassistant/components/dacia/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "dacia", + "name": "Dacia", + "integration_type": "virtual", + "supported_by": "renault" +} diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 0657f597a5de49323db7595fadbdab5bb75a3a04..0bb1324fbe0b72b9cf72e0c83fcd3a7102ce3349 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -3,7 +3,7 @@ "name": "Daikin AC", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/daikin", - "requirements": ["pydaikin==2.7.2"], + "requirements": ["pydaikin==2.8.0"], "codeowners": ["@fredrike"], "zeroconf": ["_dkapi._tcp.local."], "quality_scale": "platinum", diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 0f885e63bf7c40c58dd0502025fdc5fae28410bc..23b4b526f9a59e4da236cc1d7adfc970d2107bcd 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -50,8 +50,7 @@ async def async_setup_entry( # device supports the streamer, so assume so if it does support # advanced modes. switches.append(DaikinStreamerSwitch(daikin_api)) - if switches: - async_add_entities(switches) + async_add_entities(switches) class DaikinZoneSwitch(SwitchEntity): diff --git a/homeassistant/components/daikin/translations/nb.json b/homeassistant/components/daikin/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/daikin/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py index de736b2c599c8ff4abb399cb4c92965e806270ff..51eab3e471cbca7ee6f79d3e46490539e0d23906 100644 --- a/homeassistant/components/danfoss_air/sensor.py +++ b/homeassistant/components/danfoss_air/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, TEMP_CELSIUS +from homeassistant.const import PERCENTAGE, REVOLUTIONS_PER_MINUTE, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -75,14 +75,14 @@ def setup_platform( ["Danfoss Air Fan Step", PERCENTAGE, ReadCommand.fan_step, None, None], [ "Danfoss Air Exhaust Fan Speed", - "RPM", + REVOLUTIONS_PER_MINUTE, ReadCommand.exhaust_fan_speed, None, None, ], [ "Danfoss Air Supply Fan Speed", - "RPM", + REVOLUTIONS_PER_MINUTE, ReadCommand.supply_fan_speed, None, None, diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py index db79b82de5375275382e2666cfbde20a5eb30eaf..504029d339b4456343774e57ee891ed583e2d33b 100644 --- a/homeassistant/components/darksky/sensor.py +++ b/homeassistant/components/darksky/sensor.py @@ -18,7 +18,6 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, @@ -31,8 +30,6 @@ from homeassistant.const import ( LENGTH_KILOMETERS, LENGTH_MILES, PERCENTAGE, - PRECIPITATION_INCHES, - PRECIPITATION_MILLIMETERS_PER_HOUR, PRESSURE_MBAR, SPEED_KILOMETERS_PER_HOUR, SPEED_METERS_PER_SECOND, @@ -40,17 +37,17 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, UV_INDEX, + UnitOfVolumetricFlux, ) 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 homeassistant.util import Throttle +from homeassistant.util.unit_system import METRIC_SYSTEM _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Powered by Dark Sky" - CONF_FORECAST = "forecast" CONF_HOURLY_FORECAST = "hourly_forecast" CONF_LANGUAGE = "language" @@ -148,11 +145,11 @@ SENSOR_TYPES: dict[str, DarkskySensorEntityDescription] = { "precip_intensity": DarkskySensorEntityDescription( key="precip_intensity", name="Precip Intensity", - si_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, - us_unit=PRECIPITATION_INCHES, - ca_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, - uk_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, - uk2_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, + si_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + us_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR, + ca_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + uk_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + uk2_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, icon="mdi:weather-rainy", forecast_mode=["currently", "minutely", "hourly", "daily"], ), @@ -394,11 +391,11 @@ SENSOR_TYPES: dict[str, DarkskySensorEntityDescription] = { "precip_intensity_max": DarkskySensorEntityDescription( key="precip_intensity_max", name="Daily Max Precip Intensity", - si_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, - us_unit=PRECIPITATION_INCHES, - ca_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, - uk_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, - uk2_unit=PRECIPITATION_MILLIMETERS_PER_HOUR, + si_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + us_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR, + ca_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + uk_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + uk2_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, icon="mdi:thermometer", forecast_mode=["daily"], ), @@ -587,7 +584,7 @@ def setup_platform( if CONF_UNITS in config: units = config[CONF_UNITS] - elif hass.config.units.is_metric: + elif hass.config.units is METRIC_SYSTEM: units = "si" else: units = "us" @@ -647,8 +644,8 @@ def setup_platform( class DarkSkySensor(SensorEntity): """Implementation of a Dark Sky sensor.""" + _attr_attribution = "Powered by Dark Sky" entity_description: DarkskySensorEntityDescription - _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} def __init__( self, diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index dc0818f54b6c968447dac98b9647ab1d9693b1ab..33746d1a5ddb63b4a759125cad38183dac46bb78 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -5,5 +5,6 @@ "requirements": ["debugpy==1.6.3"], "codeowners": ["@frenck"], "quality_scale": "internal", - "iot_class": "local_push" + "iot_class": "local_push", + "integration_type": "service" } diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index f495fef45c3a16c3eaf3e67385f891a39c13fa7d..6e0c4c86d21b81fac4a8ecb52a4d8055226246ce 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -1,7 +1,9 @@ """Support for deCONZ binary sensors.""" from __future__ import annotations -from typing import TYPE_CHECKING, TypeVar +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic, TypeVar from pydeconz.interfaces.sensors import SensorResources from pydeconz.models.event import EventType @@ -19,6 +21,7 @@ from homeassistant.components.binary_sensor import ( DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE @@ -48,10 +51,130 @@ PROVIDES_EXTRA_ATTRIBUTES = ( "water", ) +T = TypeVar( + "T", + Alarm, + CarbonMonoxide, + Fire, + GenericFlag, + OpenClose, + Presence, + Vibration, + Water, + PydeconzSensorBase, +) + + +@dataclass +class DeconzBinarySensorDescriptionMixin(Generic[T]): + """Required values when describing secondary sensor attributes.""" + + update_key: str + value_fn: Callable[[T], bool | None] + + +@dataclass +class DeconzBinarySensorDescription( + BinarySensorEntityDescription, + DeconzBinarySensorDescriptionMixin[T], +): + """Class describing deCONZ binary sensor entities.""" + + instance_check: type[T] | None = None + name_suffix: str = "" + old_unique_id_suffix: str = "" + + +ENTITY_DESCRIPTIONS: tuple[DeconzBinarySensorDescription, ...] = ( + DeconzBinarySensorDescription[Alarm]( + key="alarm", + update_key="alarm", + value_fn=lambda device: device.alarm, + instance_check=Alarm, + device_class=BinarySensorDeviceClass.SAFETY, + ), + DeconzBinarySensorDescription[CarbonMonoxide]( + key="carbon_monoxide", + update_key="carbonmonoxide", + value_fn=lambda device: device.carbon_monoxide, + instance_check=CarbonMonoxide, + device_class=BinarySensorDeviceClass.CO, + ), + DeconzBinarySensorDescription[Fire]( + key="fire", + update_key="fire", + value_fn=lambda device: device.fire, + instance_check=Fire, + device_class=BinarySensorDeviceClass.SMOKE, + ), + DeconzBinarySensorDescription[Fire]( + key="in_test_mode", + update_key="test", + value_fn=lambda device: device.in_test_mode, + instance_check=Fire, + name_suffix="Test Mode", + old_unique_id_suffix="test mode", + device_class=BinarySensorDeviceClass.SMOKE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DeconzBinarySensorDescription[GenericFlag]( + key="flag", + update_key="flag", + value_fn=lambda device: device.flag, + instance_check=GenericFlag, + ), + DeconzBinarySensorDescription[OpenClose]( + key="open", + update_key="open", + value_fn=lambda device: device.open, + instance_check=OpenClose, + device_class=BinarySensorDeviceClass.OPENING, + ), + DeconzBinarySensorDescription[Presence]( + key="presence", + update_key="presence", + value_fn=lambda device: device.presence, + instance_check=Presence, + device_class=BinarySensorDeviceClass.MOTION, + ), + DeconzBinarySensorDescription[Vibration]( + key="vibration", + update_key="vibration", + value_fn=lambda device: device.vibration, + instance_check=Vibration, + device_class=BinarySensorDeviceClass.VIBRATION, + ), + DeconzBinarySensorDescription[Water]( + key="water", + update_key="water", + value_fn=lambda device: device.water, + instance_check=Water, + device_class=BinarySensorDeviceClass.MOISTURE, + ), + DeconzBinarySensorDescription[SensorResources]( + key="tampered", + update_key="tampered", + value_fn=lambda device: device.tampered, + name_suffix="Tampered", + old_unique_id_suffix="tampered", + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DeconzBinarySensorDescription[SensorResources]( + key="low_battery", + update_key="lowbattery", + value_fn=lambda device: device.low_battery, + name_suffix="Low Battery", + old_unique_id_suffix="low battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + @callback def async_update_unique_id( - hass: HomeAssistant, unique_id: str, entity_class: DeconzBinarySensor + hass: HomeAssistant, unique_id: str, description: DeconzBinarySensorDescription ) -> None: """Update unique ID to always have a suffix. @@ -59,12 +182,12 @@ def async_update_unique_id( """ ent_reg = er.async_get(hass) - new_unique_id = f"{unique_id}-{entity_class.unique_id_suffix}" + new_unique_id = f"{unique_id}-{description.key}" if ent_reg.async_get_entity_id(DOMAIN, DECONZ_DOMAIN, new_unique_id): return - if entity_class.old_unique_id_suffix: - unique_id = f'{unique_id.split("-", 1)[0]}-{entity_class.old_unique_id_suffix}' + if description.old_unique_id_suffix: + unique_id = f'{unique_id.split("-", 1)[0]}-{description.old_unique_id_suffix}' 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) @@ -84,19 +207,14 @@ async def async_setup_entry( """Add sensor from deCONZ.""" sensor = gateway.api.sensors[sensor_id] - for sensor_type, entity_class in ENTITY_CLASSES: - if TYPE_CHECKING: - assert isinstance(entity_class, DeconzBinarySensor) + for description in ENTITY_DESCRIPTIONS: if ( - not isinstance(sensor, sensor_type) - or entity_class.unique_id_suffix is not None - and getattr(sensor, entity_class.unique_id_suffix) is None - ): + description.instance_check + and not isinstance(sensor, description.instance_check) + ) or description.value_fn(sensor) is None: continue - - async_update_unique_id(hass, sensor.unique_id, entity_class) - - async_add_entities([entity_class(sensor, gateway)]) + async_update_unique_id(hass, sensor.unique_id, description) + async_add_entities([DeconzBinarySensor(sensor, gateway, description)]) gateway.register_platform_add_device_callback( async_add_sensor, @@ -104,28 +222,43 @@ async def async_setup_entry( ) -class DeconzBinarySensor(DeconzDevice[_SensorDeviceT], BinarySensorEntity): +class DeconzBinarySensor(DeconzDevice[SensorResources], BinarySensorEntity): """Representation of a deCONZ binary sensor.""" - old_unique_id_suffix = "" TYPE = DOMAIN - - def __init__(self, device: _SensorDeviceT, gateway: DeconzGateway) -> None: + entity_description: DeconzBinarySensorDescription + + def __init__( + self, + device: SensorResources, + gateway: DeconzGateway, + description: DeconzBinarySensorDescription, + ) -> None: """Initialize deCONZ binary sensor.""" + self.entity_description = description + self.unique_id_suffix = description.key + self._update_key = description.update_key + if description.name_suffix: + self._name_suffix = description.name_suffix super().__init__(device, gateway) if ( - self.unique_id_suffix in PROVIDES_EXTRA_ATTRIBUTES + self.entity_description.key in PROVIDES_EXTRA_ATTRIBUTES and self._update_keys is not None ): self._update_keys.update({"on", "state"}) + @property + def is_on(self) -> bool | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._device) + @property def extra_state_attributes(self) -> dict[str, bool | float | int | list | None]: """Return the state attributes of the sensor.""" attr: dict[str, bool | float | int | list | None] = {} - if self.unique_id_suffix not in PROVIDES_EXTRA_ATTRIBUTES: + if self.entity_description.key not in PROVIDES_EXTRA_ATTRIBUTES: return attr if self._device.on is not None: @@ -145,179 +278,3 @@ class DeconzBinarySensor(DeconzDevice[_SensorDeviceT], BinarySensorEntity): attr[ATTR_VIBRATIONSTRENGTH] = self._device.vibration_strength return attr - - -class DeconzAlarmBinarySensor(DeconzBinarySensor[Alarm]): - """Representation of a deCONZ alarm binary sensor.""" - - unique_id_suffix = "alarm" - _update_key = "alarm" - - _attr_device_class = BinarySensorDeviceClass.SAFETY - - @property - def is_on(self) -> bool: - """Return the state of the sensor.""" - return self._device.alarm - - -class DeconzCarbonMonoxideBinarySensor(DeconzBinarySensor[CarbonMonoxide]): - """Representation of a deCONZ carbon monoxide binary sensor.""" - - unique_id_suffix = "carbon_monoxide" - _update_key = "carbonmonoxide" - - _attr_device_class = BinarySensorDeviceClass.CO - - @property - def is_on(self) -> bool: - """Return the state of the sensor.""" - return self._device.carbon_monoxide - - -class DeconzFireBinarySensor(DeconzBinarySensor[Fire]): - """Representation of a deCONZ fire binary sensor.""" - - unique_id_suffix = "fire" - _update_key = "fire" - - _attr_device_class = BinarySensorDeviceClass.SMOKE - - @property - def is_on(self) -> bool: - """Return the state of the sensor.""" - return self._device.fire - - -class DeconzFireInTestModeBinarySensor(DeconzBinarySensor[Fire]): - """Representation of a deCONZ fire in-test-mode binary sensor.""" - - _name_suffix = "Test Mode" - unique_id_suffix = "in_test_mode" - old_unique_id_suffix = "test mode" - _update_key = "test" - - _attr_device_class = BinarySensorDeviceClass.SMOKE - _attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def is_on(self) -> bool: - """Return the state of the sensor.""" - return self._device.in_test_mode - - -class DeconzFlagBinarySensor(DeconzBinarySensor[GenericFlag]): - """Representation of a deCONZ generic flag binary sensor.""" - - unique_id_suffix = "flag" - _update_key = "flag" - - @property - def is_on(self) -> bool: - """Return the state of the sensor.""" - return self._device.flag - - -class DeconzOpenCloseBinarySensor(DeconzBinarySensor[OpenClose]): - """Representation of a deCONZ open/close binary sensor.""" - - unique_id_suffix = "open" - _update_key = "open" - - _attr_device_class = BinarySensorDeviceClass.OPENING - - @property - def is_on(self) -> bool: - """Return the state of the sensor.""" - return self._device.open - - -class DeconzPresenceBinarySensor(DeconzBinarySensor[Presence]): - """Representation of a deCONZ presence binary sensor.""" - - unique_id_suffix = "presence" - _update_key = "presence" - - _attr_device_class = BinarySensorDeviceClass.MOTION - - @property - def is_on(self) -> bool: - """Return the state of the sensor.""" - return self._device.presence - - -class DeconzVibrationBinarySensor(DeconzBinarySensor[Vibration]): - """Representation of a deCONZ vibration binary sensor.""" - - unique_id_suffix = "vibration" - _update_key = "vibration" - - _attr_device_class = BinarySensorDeviceClass.VIBRATION - - @property - def is_on(self) -> bool: - """Return the state of the sensor.""" - return self._device.vibration - - -class DeconzWaterBinarySensor(DeconzBinarySensor[Water]): - """Representation of a deCONZ water binary sensor.""" - - unique_id_suffix = "water" - _update_key = "water" - - _attr_device_class = BinarySensorDeviceClass.MOISTURE - - @property - def is_on(self) -> bool: - """Return the state of the sensor.""" - return self._device.water - - -class DeconzTamperedCommonBinarySensor(DeconzBinarySensor[SensorResources]): - """Representation of a deCONZ tampered binary sensor.""" - - _name_suffix = "Tampered" - unique_id_suffix = "tampered" - old_unique_id_suffix = "tampered" - _update_key = "tampered" - - _attr_device_class = BinarySensorDeviceClass.TAMPER - _attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def is_on(self) -> bool | None: - """Return the state of the sensor.""" - return self._device.tampered - - -class DeconzLowBatteryCommonBinarySensor(DeconzBinarySensor[SensorResources]): - """Representation of a deCONZ low battery binary sensor.""" - - _name_suffix = "Low Battery" - unique_id_suffix = "low_battery" - old_unique_id_suffix = "low battery" - _update_key = "lowbattery" - - _attr_device_class = BinarySensorDeviceClass.BATTERY - _attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def is_on(self) -> bool | None: - """Return the state of the sensor.""" - return self._device.low_battery - - -ENTITY_CLASSES = ( - (Alarm, DeconzAlarmBinarySensor), - (CarbonMonoxide, DeconzCarbonMonoxideBinarySensor), - (Fire, DeconzFireBinarySensor), - (Fire, DeconzFireInTestModeBinarySensor), - (GenericFlag, DeconzFlagBinarySensor), - (OpenClose, DeconzOpenCloseBinarySensor), - (Presence, DeconzPresenceBinarySensor), - (Vibration, DeconzVibrationBinarySensor), - (Water, DeconzWaterBinarySensor), - (PydeconzSensorBase, DeconzTamperedCommonBinarySensor), - (PydeconzSensorBase, DeconzLowBatteryCommonBinarySensor), -) diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 0d13f2639da61800414a9e16db4195e0d4f5b3ac..c5b9571ed341cbed2a505a9f1535ad19ca53923a 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -174,7 +174,7 @@ class DeconzThermostat(DeconzDevice[Thermostat], ClimateEntity): ) @property - def hvac_action(self) -> str | None: + def hvac_action(self) -> HVACAction: """Return current hvac operation ie. heat, cool. Preset 'BOOST' is interpreted as 'state_on'. diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index e4d9a818a4ebdfaff5e80cfae654d741ad8b4d38..8c63a47f59ce7362f616e98caec9e2be49e55c59 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -482,6 +482,12 @@ LIDL_SILVERCREST_DOORBELL = { (CONF_SHORT_PRESS, ""): {CONF_EVENT: 1002}, } +LIDL_SILVERCREST_BUTTON_REMOTE_MODEL = "TS004F" +LIDL_SILVERCREST_BUTTON_REMOTE = { + (CONF_SHORT_PRESS, ""): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, ""): {CONF_EVENT: 1004}, +} + LIGHTIFIY_FOUR_BUTTON_REMOTE_MODEL = "Switch-LIGHTIFY" LIGHTIFIY_FOUR_BUTTON_REMOTE_4X_MODEL = "Switch 4x-LIGHTIFY" LIGHTIFIY_FOUR_BUTTON_REMOTE_4X_EU_MODEL = "Switch 4x EU-LIGHTIFY" @@ -607,6 +613,7 @@ REMOTES = { LEGRAND_ZGP_TOGGLE_SWITCH_MODEL: LEGRAND_ZGP_TOGGLE_SWITCH, LEGRAND_ZGP_SCENE_SWITCH_MODEL: LEGRAND_ZGP_SCENE_SWITCH, LIDL_SILVERCREST_DOORBELL_MODEL: LIDL_SILVERCREST_DOORBELL, + LIDL_SILVERCREST_BUTTON_REMOTE_MODEL: LIDL_SILVERCREST_BUTTON_REMOTE, LIGHTIFIY_FOUR_BUTTON_REMOTE_MODEL: LIGHTIFIY_FOUR_BUTTON_REMOTE, LIGHTIFIY_FOUR_BUTTON_REMOTE_4X_MODEL: LIGHTIFIY_FOUR_BUTTON_REMOTE, LIGHTIFIY_FOUR_BUTTON_REMOTE_4X_EU_MODEL: LIGHTIFIY_FOUR_BUTTON_REMOTE, diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 38c78d849da7633ab787ce7ee358a7c032bb19fe..5de15b16177b83307ed1504d6de88ad2ef02c39f 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==104"], + "requirements": ["pydeconz==105"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", @@ -13,5 +13,6 @@ "codeowners": ["@Kane610"], "quality_scale": "platinum", "iot_class": "local_push", + "integration_type": "hub", "loggers": ["pydeconz"] } diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index 9baa54efb56513b21c11221e577bff8df25a4351..789a155477a6e36b34141f0e4b584eec31549ab0 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -1,11 +1,15 @@ -"""Support for configuring different deCONZ sensors.""" +"""Support for configuring different deCONZ numbers.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +from typing import Any, Generic, TypeVar +from pydeconz.gateway import DeconzSession +from pydeconz.interfaces.sensors import SensorResources from pydeconz.models.event import EventType +from pydeconz.models.sensor import SensorBase as PydeconzSensorBase from pydeconz.models.sensor.presence import Presence from homeassistant.components.number import ( @@ -17,39 +21,76 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er +from .const import DOMAIN as DECONZ_DOMAIN from .deconz_device import DeconzDevice from .gateway import DeconzGateway, get_gateway_from_config_entry +T = TypeVar("T", Presence, PydeconzSensorBase) + @dataclass -class DeconzNumberDescriptionMixin: +class DeconzNumberDescriptionMixin(Generic[T]): """Required values when describing deCONZ number entities.""" - suffix: str + instance_check: type[T] + name_suffix: str + set_fn: Callable[[DeconzSession, str, int], Coroutine[Any, Any, dict[str, Any]]] update_key: str - value_fn: Callable[[Presence], float | None] + value_fn: Callable[[T], float | None] @dataclass -class DeconzNumberDescription(NumberEntityDescription, DeconzNumberDescriptionMixin): +class DeconzNumberDescription(NumberEntityDescription, DeconzNumberDescriptionMixin[T]): """Class describing deCONZ number entities.""" -ENTITY_DESCRIPTIONS = { - Presence: [ - DeconzNumberDescription( - key="delay", - value_fn=lambda device: device.delay, - suffix="Delay", - update_key="delay", - native_max_value=65535, - native_min_value=0, - native_step=1, - entity_category=EntityCategory.CONFIG, - ) - ] -} +ENTITY_DESCRIPTIONS: tuple[DeconzNumberDescription, ...] = ( + DeconzNumberDescription[Presence]( + key="delay", + instance_check=Presence, + name_suffix="Delay", + set_fn=lambda api, id, v: api.sensors.presence.set_config(id=id, delay=v), + update_key="delay", + value_fn=lambda device: device.delay, + native_max_value=65535, + native_min_value=0, + native_step=1, + entity_category=EntityCategory.CONFIG, + ), + DeconzNumberDescription[Presence]( + key="duration", + instance_check=Presence, + name_suffix="Duration", + set_fn=lambda api, id, v: api.sensors.presence.set_config(id=id, duration=v), + update_key="duration", + value_fn=lambda device: device.duration, + native_max_value=65535, + native_min_value=0, + native_step=1, + entity_category=EntityCategory.CONFIG, + ), +) + + +@callback +def async_update_unique_id( + hass: HomeAssistant, unique_id: str, description: DeconzNumberDescription +) -> None: + """Update unique ID base to be on full unique ID rather than device serial. + + Introduced with release 2022.11. + """ + 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 + + unique_id = f'{unique_id.split("-", 1)[0]}-{description.key}' + 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( @@ -66,12 +107,14 @@ async def async_setup_entry( """Add sensor from deCONZ.""" sensor = gateway.api.sensors.presence[sensor_id] - for description in ENTITY_DESCRIPTIONS.get(type(sensor), []): + for description in ENTITY_DESCRIPTIONS: if ( - not hasattr(sensor, description.key) + not isinstance(sensor, description.instance_check) or description.value_fn(sensor) is None ): continue + if description.key == "delay": + async_update_unique_id(hass, sensor.unique_id, description) async_add_entities([DeconzNumber(sensor, gateway, description)]) gateway.register_platform_add_device_callback( @@ -81,21 +124,23 @@ async def async_setup_entry( ) -class DeconzNumber(DeconzDevice[Presence], NumberEntity): +class DeconzNumber(DeconzDevice[SensorResources], NumberEntity): """Representation of a deCONZ number entity.""" TYPE = DOMAIN + entity_description: DeconzNumberDescription def __init__( self, - device: Presence, + device: SensorResources, gateway: DeconzGateway, description: DeconzNumberDescription, ) -> None: """Initialize deCONZ number entity.""" - self.entity_description: DeconzNumberDescription = description - self._update_key = self.entity_description.update_key - self._name_suffix = description.suffix + self.entity_description = description + self.unique_id_suffix = description.key + self._name_suffix = description.name_suffix + self._update_key = description.update_key super().__init__(device, gateway) @property @@ -105,12 +150,8 @@ class DeconzNumber(DeconzDevice[Presence], NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set sensor config.""" - await self.gateway.api.sensors.presence.set_config( - id=self._device.resource_id, - delay=int(value), + await self.entity_description.set_fn( + self.gateway.api, + self._device.resource_id, + int(value), ) - - @property - def unique_id(self) -> str: - """Return a unique identifier for this entity.""" - return f"{self.serial}-{self.entity_description.suffix.lower()}" diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 09d248756fc868b1e8229eee9e9f6382f1e49616..66c186e20d7f66c60129438b9a09190ef01f7cfa 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -1,12 +1,15 @@ """Support for deCONZ sensors.""" + from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime +from typing import Generic, TypeVar from pydeconz.interfaces.sensors import SensorResources from pydeconz.models.event import EventType +from pydeconz.models.sensor import SensorBase as PydeconzSensorBase from pydeconz.models.sensor.air_quality import AirQuality from pydeconz.models.sensor.consumption import Consumption from pydeconz.models.sensor.daylight import DAYLIGHT_STATUS, Daylight @@ -30,7 +33,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_VOLTAGE, - CONCENTRATION_PARTS_PER_BILLION, ENERGY_KILO_WATT_HOUR, LIGHT_LUX, PERCENTAGE, @@ -67,171 +69,154 @@ ATTR_DAYLIGHT = "daylight" ATTR_EVENT_ID = "event_id" +T = TypeVar( + "T", + AirQuality, + Consumption, + Daylight, + GenericStatus, + Humidity, + LightLevel, + Power, + Pressure, + Temperature, + Time, + PydeconzSensorBase, +) + + @dataclass -class DeconzSensorDescriptionMixin: +class DeconzSensorDescriptionMixin(Generic[T]): """Required values when describing secondary sensor attributes.""" update_key: str - value_fn: Callable[[SensorResources], float | int | str | None] + value_fn: Callable[[T], datetime | StateType] @dataclass -class DeconzSensorDescription( - SensorEntityDescription, - DeconzSensorDescriptionMixin, -): +class DeconzSensorDescription(SensorEntityDescription, DeconzSensorDescriptionMixin[T]): """Class describing deCONZ binary sensor entities.""" - suffix: str = "" - - -ENTITY_DESCRIPTIONS = { - AirQuality: [ - DeconzSensorDescription( - key="air_quality", - value_fn=lambda device: device.air_quality - if isinstance(device, AirQuality) - else None, - update_key="airquality", - state_class=SensorStateClass.MEASUREMENT, - ), - DeconzSensorDescription( - key="air_quality_ppb", - value_fn=lambda device: device.air_quality_ppb - if isinstance(device, AirQuality) - else None, - suffix="PPB", - update_key="airqualityppb", - device_class=SensorDeviceClass.AQI, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, - ), - ], - Consumption: [ - DeconzSensorDescription( - key="consumption", - value_fn=lambda device: device.scaled_consumption - if isinstance(device, Consumption) and isinstance(device.consumption, int) - else None, - update_key="consumption", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - ) - ], - Daylight: [ - DeconzSensorDescription( - key="daylight_status", - value_fn=lambda device: DAYLIGHT_STATUS[device.daylight_status] - if isinstance(device, Daylight) - else None, - update_key="status", - icon="mdi:white-balance-sunny", - entity_registry_enabled_default=False, - ) - ], - GenericStatus: [ - DeconzSensorDescription( - key="status", - value_fn=lambda device: device.status - if isinstance(device, GenericStatus) - else None, - update_key="status", - ) - ], - Humidity: [ - DeconzSensorDescription( - key="humidity", - value_fn=lambda device: device.scaled_humidity - if isinstance(device, Humidity) and isinstance(device.humidity, int) - else None, - update_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - ) - ], - LightLevel: [ - DeconzSensorDescription( - key="light_level", - value_fn=lambda device: device.scaled_light_level - if isinstance(device, LightLevel) and isinstance(device.light_level, int) - else None, - update_key="lightlevel", - device_class=SensorDeviceClass.ILLUMINANCE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=LIGHT_LUX, - ) - ], - Power: [ - DeconzSensorDescription( - key="power", - value_fn=lambda device: device.power if isinstance(device, Power) else None, - update_key="power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=POWER_WATT, - ) - ], - Pressure: [ - DeconzSensorDescription( - key="pressure", - value_fn=lambda device: device.pressure - if isinstance(device, Pressure) - else None, - update_key="pressure", - device_class=SensorDeviceClass.PRESSURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PRESSURE_HPA, - ) - ], - Temperature: [ - DeconzSensorDescription( - key="temperature", - value_fn=lambda device: device.scaled_temperature - if isinstance(device, Temperature) and isinstance(device.temperature, int) - else None, - update_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=TEMP_CELSIUS, - ) - ], - Time: [ - DeconzSensorDescription( - key="last_set", - value_fn=lambda device: device.last_set - if isinstance(device, Time) - else None, - update_key="lastset", - device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.TOTAL_INCREASING, - ) - ], -} - - -COMMON_SENSOR_DESCRIPTIONS = [ - DeconzSensorDescription( + instance_check: type[T] | None = None + name_suffix: str = "" + old_unique_id_suffix: str = "" + + +ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( + DeconzSensorDescription[AirQuality]( + key="air_quality", + update_key="airquality", + value_fn=lambda device: device.air_quality, + instance_check=AirQuality, + state_class=SensorStateClass.MEASUREMENT, + ), + DeconzSensorDescription[AirQuality]( + key="air_quality_ppb", + update_key="airqualityppb", + value_fn=lambda device: device.air_quality_ppb, + instance_check=AirQuality, + name_suffix="PPB", + old_unique_id_suffix="ppb", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + ), + DeconzSensorDescription[Consumption]( + key="consumption", + update_key="consumption", + value_fn=lambda device: device.scaled_consumption, + instance_check=Consumption, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DeconzSensorDescription[Daylight]( + key="daylight_status", + update_key="status", + value_fn=lambda device: DAYLIGHT_STATUS[device.daylight_status], + instance_check=Daylight, + icon="mdi:white-balance-sunny", + entity_registry_enabled_default=False, + ), + DeconzSensorDescription[GenericStatus]( + key="status", + update_key="status", + value_fn=lambda device: device.status, + instance_check=GenericStatus, + ), + DeconzSensorDescription[Humidity]( + key="humidity", + update_key="humidity", + value_fn=lambda device: device.scaled_humidity, + instance_check=Humidity, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + DeconzSensorDescription[LightLevel]( + key="light_level", + update_key="lightlevel", + value_fn=lambda device: device.scaled_light_level, + instance_check=LightLevel, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=LIGHT_LUX, + ), + DeconzSensorDescription[Power]( + key="power", + update_key="power", + value_fn=lambda device: device.power, + instance_check=Power, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + ), + DeconzSensorDescription[Pressure]( + key="pressure", + update_key="pressure", + value_fn=lambda device: device.pressure, + instance_check=Pressure, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PRESSURE_HPA, + ), + DeconzSensorDescription[Temperature]( + key="temperature", + update_key="temperature", + value_fn=lambda device: device.scaled_temperature, + instance_check=Temperature, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + DeconzSensorDescription[Time]( + key="last_set", + update_key="lastset", + value_fn=lambda device: dt_util.parse_datetime(device.last_set), + instance_check=Time, + device_class=SensorDeviceClass.TIMESTAMP, + ), + DeconzSensorDescription[SensorResources]( key="battery", - value_fn=lambda device: device.battery, - suffix="Battery", update_key="battery", + value_fn=lambda device: device.battery, + name_suffix="Battery", + old_unique_id_suffix="battery", device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - DeconzSensorDescription( + DeconzSensorDescription[SensorResources]( key="internal_temperature", - value_fn=lambda device: device.internal_temperature, - suffix="Temperature", update_key="temperature", + value_fn=lambda device: device.internal_temperature, + name_suffix="Temperature", + old_unique_id_suffix="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, ), -] +) @callback @@ -248,8 +233,8 @@ def async_update_unique_id( 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 description.old_unique_id_suffix: + unique_id = f'{unique_id.split("-", 1)[0]}-{description.old_unique_id_suffix}' 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) @@ -265,7 +250,9 @@ async def async_setup_entry( gateway.entities[DOMAIN] = set() known_device_entities: dict[str, set[str]] = { - description.key: set() for description in COMMON_SENSOR_DESCRIPTIONS + description.key: set() + for description in ENTITY_DESCRIPTIONS + if description.instance_check is None } @callback @@ -274,17 +261,17 @@ async def async_setup_entry( sensor = gateway.api.sensors[sensor_id] entities: list[DeconzSensor] = [] - for description in ( - ENTITY_DESCRIPTIONS.get(type(sensor), []) + COMMON_SENSOR_DESCRIPTIONS - ): - no_sensor_data = False - if ( - not hasattr(sensor, description.key) - or description.value_fn(sensor) is None + for description in ENTITY_DESCRIPTIONS: + if description.instance_check and not isinstance( + sensor, description.instance_check ): + continue + + no_sensor_data = False + if description.value_fn(sensor) is None: no_sensor_data = True - if description in COMMON_SENSOR_DESCRIPTIONS: + if description.instance_check is None: if ( sensor.type.startswith("CLIP") or (no_sensor_data and description.key != "battery") @@ -296,7 +283,10 @@ async def async_setup_entry( continue known_device_entities[description.key].add(unique_id) if no_sensor_data and description.key == "battery": - DeconzBatteryTracker(sensor_id, gateway, async_add_entities) + async_update_unique_id(hass, sensor.unique_id, description) + DeconzBatteryTracker( + sensor_id, gateway, description, async_add_entities + ) continue if no_sensor_data: @@ -327,9 +317,10 @@ class DeconzSensor(DeconzDevice[SensorResources], SensorEntity): ) -> None: """Initialize deCONZ sensor.""" self.entity_description = description + self.unique_id_suffix = description.key self._update_key = description.update_key - if description.suffix: - self._name_suffix = description.suffix + if description.name_suffix: + self._name_suffix = description.name_suffix super().__init__(device, gateway) if ( @@ -338,18 +329,9 @@ class DeconzSensor(DeconzDevice[SensorResources], SensorEntity): ): self._update_keys.update({"on", "state"}) - @property - def unique_id(self) -> str: - """Return a unique identifier for this device.""" - return f"{self._device.unique_id}-{self.entity_description.key}" - @property def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" - if self.entity_description.device_class is SensorDeviceClass.TIMESTAMP: - value = self.entity_description.value_fn(self._device) - assert isinstance(value, str) - return dt_util.parse_datetime(value) return self.entity_description.value_fn(self._device) @property @@ -399,19 +381,21 @@ class DeconzBatteryTracker: self, sensor_id: str, gateway: DeconzGateway, + description: DeconzSensorDescription, async_add_entities: AddEntitiesCallback, ) -> None: """Set up tracker.""" self.sensor = gateway.api.sensors[sensor_id] self.gateway = gateway + self.description = description self.async_add_entities = async_add_entities self.unsubscribe = self.sensor.subscribe(self.async_update_callback) @callback def async_update_callback(self) -> None: """Update the device's state.""" - if "battery" in self.sensor.changed_keys: + if self.description.update_key in self.sensor.changed_keys: self.unsubscribe() - desc = COMMON_SENSOR_DESCRIPTIONS[0] - async_update_unique_id(self.gateway.hass, self.sensor.unique_id, desc) - self.async_add_entities([DeconzSensor(self.sensor, self.gateway, desc)]) + self.async_add_entities( + [DeconzSensor(self.sensor, self.gateway, self.description)] + ) diff --git a/homeassistant/components/deconz/translations/bg.json b/homeassistant/components/deconz/translations/bg.json index f8b1e351fb08d055a55b2ce5620c3962b40c5c6f..b047c3616813bc8c9c5372c3228fd2e6422180f3 100644 --- a/homeassistant/components/deconz/translations/bg.json +++ b/homeassistant/components/deconz/translations/bg.json @@ -35,6 +35,10 @@ "button_2": "\u0412\u0442\u043e\u0440\u0438 \u0431\u0443\u0442\u043e\u043d", "button_3": "\u0422\u0440\u0435\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", "button_4": "\u0427\u0435\u0442\u0432\u044a\u0440\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_5": "\u041f\u0435\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_6": "\u0428\u0435\u0441\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_7": "\u0421\u0435\u0434\u043c\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_8": "\u041e\u0441\u043c\u0438 \u0431\u0443\u0442\u043e\u043d", "close": "\u0417\u0430\u0442\u0432\u0430\u0440\u044f\u043d\u0435", "dim_down": "\u0417\u0430\u0442\u044a\u043c\u043d\u044f\u0432\u0430\u043d\u0435", "dim_up": "\u041e\u0441\u0432\u0435\u0442\u044f\u0432\u0430\u043d\u0435", diff --git a/homeassistant/components/deconz/translations/de.json b/homeassistant/components/deconz/translations/de.json index 28308f10f74f2210045470ae31a5047115d44c96..626ccf613cce781b29b725b7605a3b95dbb33823 100644 --- a/homeassistant/components/deconz/translations/de.json +++ b/homeassistant/components/deconz/translations/de.json @@ -42,10 +42,10 @@ "button_2": "Zweite Taste", "button_3": "Dritte Taste", "button_4": "Vierte Taste", - "button_5": "Taste 5", - "button_6": "Taste 6", - "button_7": "Taste 7", - "button_8": "Taste 8", + "button_5": "5. Taste", + "button_6": "6. Taste", + "button_7": "7. Taste", + "button_8": "8. Taste", "close": "Schlie\u00dfen", "dim_down": "Dimmer runter", "dim_up": "Dimmer hoch", diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index d33aee6e030c39ea88897f291f3323ede044b0d4..8e1a2f01168656ca750e20b814c1556b180ba4e9 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -21,6 +21,7 @@ "input_select", "input_text", "logbook", + "logger", "map", "media_source", "mobile_app", diff --git a/homeassistant/components/demo/translations/bg.json b/homeassistant/components/demo/translations/bg.json index 2ecf8f371eb22f6d0bc98638db235adcd8ba8614..bd761c705ffd01876b8b821d135bf781db06ec7b 100644 --- a/homeassistant/components/demo/translations/bg.json +++ b/homeassistant/components/demo/translations/bg.json @@ -10,6 +10,9 @@ } }, "title": "\u0417\u0430\u0445\u0440\u0430\u043d\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0435 \u0435 \u0441\u0442\u0430\u0431\u0438\u043b\u043d\u043e" + }, + "unfixable_problem": { + "title": "\u0422\u043e\u0432\u0430 \u043d\u0435 \u0435 \u043f\u043e\u043f\u0440\u0430\u0432\u0438\u043c \u043f\u0440\u043e\u0431\u043b\u0435\u043c" } }, "title": "\u0414\u0435\u043c\u043e\u043d\u0441\u0442\u0440\u0430\u0446\u0438\u044f" diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index b43dbe3acb7636f7a5d6e284890f5b17dbb83f97..f7212801174c6aef59c18c2a9fd4b91dffa07a90 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -3,7 +3,7 @@ "name": "Denon AVR Network Receivers", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/denonavr", - "requirements": ["denonavr==0.10.11"], + "requirements": ["denonavr==0.10.12"], "codeowners": ["@ol-iver", "@starkillerOG"], "ssdp": [ { @@ -56,8 +56,5 @@ } ], "iot_class": "local_polling", - "loggers": ["denonavr"], - "supported_brands": { - "marantz": "Marantz" - } + "loggers": ["denonavr"] } diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 95e0628d534af319b6acbdd1793e5f1bdfe26d18..6c566aa45e30eb8cb0700cac983e299f5f22a5e8 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -35,6 +35,7 @@ async def async_setup_entry( "devolo.model.Thermostat:Valve", "devolo.model.Room:Thermostat", "devolo.model.Eurotronic:Spirit:Device", + "unk.model.Danfoss:Thermostat", ): entities.append( DevoloClimateDeviceEntity( diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index 0de3cd7f07dabbb7c2b60589294306c7eda8e002..c6a2542033314e94401620e882d58e16b97561f3 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -1,6 +1,7 @@ { "domain": "devolo_home_control", "name": "devolo Home Control", + "integration_type": "hub", "documentation": "https://www.home-assistant.io/integrations/devolo_home_control", "requirements": ["devolo-home-control-api==0.18.2"], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/devolo_home_control/translations/bg.json b/homeassistant/components/devolo_home_control/translations/bg.json index d5f922c14ffce1e77fbc32c70c1c10961acea68d..47ab5f03cbc55fa88e1ceb1c4fe217890c17df67 100644 --- a/homeassistant/components/devolo_home_control/translations/bg.json +++ b/homeassistant/components/devolo_home_control/translations/bg.json @@ -7,7 +7,15 @@ "step": { "user": { "data": { - "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u0418\u043c\u0435\u0439\u043b / devolo ID" + } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "mydevolo URL", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u0418\u043c\u0435\u0439\u043b / devolo ID" } } } diff --git a/homeassistant/components/devolo_home_control/translations/no.json b/homeassistant/components/devolo_home_control/translations/no.json index 1f1ee69ae47aa594dda4cd44c8424ca6a50299e1..82f71bc706594261d74ac967b5bc61d08e72a219 100644 --- a/homeassistant/components/devolo_home_control/translations/no.json +++ b/homeassistant/components/devolo_home_control/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 5cf91325d7082ffe29324aff6dd4e034115013d1..4c54dc721e1baa540c8d548827d41dc7e3077376 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -10,7 +10,7 @@ from devolo_plc_api.exceptions.device import DeviceNotFound, DeviceUnavailable from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.httpx_client import get_async_client @@ -40,6 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ip=entry.data[CONF_IP_ADDRESS], zeroconf_instance=zeroconf_instance ) await device.async_connect(session_instance=async_client) + device.password = entry.data.get( + CONF_PASSWORD, "" # This key was added in HA Core 2022.6 + ) except DeviceNotFound as err: raise ConfigEntryNotReady( f"Unable to connect to {entry.data[CONF_IP_ADDRESS]}" diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py index c96126f43e2af390750cd5df5f4e01d61fb5dba1..0acdc9cfa6440bb69e729a354284d5fd99cceae2 100644 --- a/homeassistant/components/devolo_home_network/config_flow.py +++ b/homeassistant/components/devolo_home_network/config_flow.py @@ -1,6 +1,7 @@ """Config flow for devolo Home Network integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -10,7 +11,7 @@ import voluptuous as vol from homeassistant import config_entries, core from homeassistant.components import zeroconf -from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_NAME, CONF_PASSWORD from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.httpx_client import get_async_client @@ -19,6 +20,7 @@ from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_IP_ADDRESS): str}) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_PASSWORD): str}) async def validate_input( @@ -68,6 +70,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: await self.async_set_unique_id(info[SERIAL_NUMBER], raise_on_progress=False) self._abort_if_unique_id_configured() + user_input[CONF_PASSWORD] = "" return self.async_create_entry(title=info[TITLE], data=user_input) return self.async_show_form( @@ -100,9 +103,46 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: data = { CONF_IP_ADDRESS: self.context[CONF_HOST], + CONF_PASSWORD: "", } return self.async_create_entry(title=title, data=data) return self.async_show_form( step_id="zeroconf_confirm", description_placeholders={"host_name": title}, ) + + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + """Handle reauthentication.""" + self.context[CONF_HOST] = data[CONF_IP_ADDRESS] + self.context["title_placeholders"][PRODUCT] = self.hass.data[DOMAIN][ + self.context["entry_id"] + ]["device"].product + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by reauthentication.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + ) + + reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert reauth_entry is not None + + data = { + CONF_IP_ADDRESS: self.context[CONF_HOST], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + self.hass.config_entries.async_update_entry( + reauth_entry, + data=data, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 0e3e47d9320f1c7f8c466d66adfa875c3eeebce4..f535136680ac9547929ce76c09645444d2d104f4 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -55,8 +55,7 @@ async def async_setup_entry( ) ) tracked.add(station[MAC_ADDRESS]) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) @callback def restore_entities() -> None: @@ -82,8 +81,7 @@ async def async_setup_entry( ) tracked.add(mac_address) - if missing: - async_add_entities(missing) + async_add_entities(missing) if device.device and "wifi1" in device.device.features: restore_entities() diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index 94f26c8a615a752ba9ecff89842ace2c5e588cba..945c314a1961adcd01522e3e9ee6ede094b46bee 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -1,6 +1,7 @@ { "domain": "devolo_home_network", "name": "devolo Home Network", + "integration_type": "device", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/devolo_home_network", "requirements": ["devolo-plc-api==0.8.0"], diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 63be57d94857726f4857498b9aa48a38aaadd77a..6c320710a1ba40296f2fe7a7c5ca089ebb78fda0 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -8,6 +8,11 @@ "ip_address": "[%key:common::config_flow::data::ip%]" } }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, "zeroconf_confirm": { "description": "Do you want to add the devolo home network device with the hostname `{host_name}` to Home Assistant?", "title": "Discovered devolo home network device" @@ -19,7 +24,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "home_control": "The devolo Home Control Central Unit does not work with this integration." + "home_control": "The devolo Home Control Central Unit does not work with this integration.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/devolo_home_network/translations/bg.json b/homeassistant/components/devolo_home_network/translations/bg.json index c1dc13fe2d7aca7b6a90aaf18fc4d62083682c38..a90c099889a9b803e06111216d6777310b581c4b 100644 --- a/homeassistant/components/devolo_home_network/translations/bg.json +++ b/homeassistant/components/devolo_home_network/translations/bg.json @@ -1,7 +1,8 @@ { "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" + "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", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u0442\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u0430" }, "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", @@ -9,6 +10,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + }, "user": { "data": { "ip_address": "IP \u0430\u0434\u0440\u0435\u0441" diff --git a/homeassistant/components/devolo_home_network/translations/ca.json b/homeassistant/components/devolo_home_network/translations/ca.json index c175a1a1246f8e7a8992e52030b54ed5feb9c46c..c0278a4733d7d70981f380833e50a83c6a452513 100644 --- a/homeassistant/components/devolo_home_network/translations/ca.json +++ b/homeassistant/components/devolo_home_network/translations/ca.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", - "home_control": "La unitat central de control dom\u00e8stic de devolo no funciona amb aquesta integraci\u00f3." + "home_control": "La unitat central de control dom\u00e8stic de devolo no funciona amb aquesta integraci\u00f3.", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "Contrasenya" + } + }, "user": { "data": { "ip_address": "Adre\u00e7a IP" diff --git a/homeassistant/components/devolo_home_network/translations/de.json b/homeassistant/components/devolo_home_network/translations/de.json index c018c757d16530d2fffd91bcfd36e2f51a56c591..8a850e01bcf281978c4098623af34c5e559fa1d9 100644 --- a/homeassistant/components/devolo_home_network/translations/de.json +++ b/homeassistant/components/devolo_home_network/translations/de.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", - "home_control": "Die devolo Home Control-Zentraleinheit funktioniert nicht mit dieser Integration." + "home_control": "Die devolo Home Control-Zentraleinheit funktioniert nicht mit dieser Integration.", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "Passwort" + } + }, "user": { "data": { "ip_address": "IP-Adresse" diff --git a/homeassistant/components/devolo_home_network/translations/en.json b/homeassistant/components/devolo_home_network/translations/en.json index 39c0b6d331f5ee30b40e8d76ff04591de632ad94..e98984738b0a79fea2a7f0fb86fde9b9219cc003 100644 --- a/homeassistant/components/devolo_home_network/translations/en.json +++ b/homeassistant/components/devolo_home_network/translations/en.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Device is already configured", - "home_control": "The devolo Home Control Central Unit does not work with this integration." + "home_control": "The devolo Home Control Central Unit does not work with this integration.", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "Password" + } + }, "user": { "data": { "ip_address": "IP Address" diff --git a/homeassistant/components/devolo_home_network/translations/es.json b/homeassistant/components/devolo_home_network/translations/es.json index 645f03475548f861dbfc9b344696985c2279c208..9eea1b5241593909c30df6118b116687f2fa1c8b 100644 --- a/homeassistant/components/devolo_home_network/translations/es.json +++ b/homeassistant/components/devolo_home_network/translations/es.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "El dispositivo ya est\u00e1 configurado", - "home_control": "La unidad central Home Control de devolo no funciona con esta integraci\u00f3n." + "home_control": "La unidad central Home Control de devolo no funciona con esta integraci\u00f3n.", + "reauth_successful": "La autenticaci\u00f3n se volvi\u00f3 a realizar correctamente" }, "error": { "cannot_connect": "No se pudo conectar", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a" + } + }, "user": { "data": { "ip_address": "Direcci\u00f3n IP" diff --git a/homeassistant/components/devolo_home_network/translations/et.json b/homeassistant/components/devolo_home_network/translations/et.json index dff9df53c72be21085c30aeb9b4b1b269b04c6df..6e985371e93b564afcd02842f06513f7fa17177f 100644 --- a/homeassistant/components/devolo_home_network/translations/et.json +++ b/homeassistant/components/devolo_home_network/translations/et.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", - "home_control": "Devolo Home Controli kesk\u00fcksus ei t\u00f6\u00f6ta selle sidumisega." + "home_control": "Devolo Home Controli kesk\u00fcksus ei t\u00f6\u00f6ta selle sidumisega.", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "cannot_connect": "\u00dchendamine nurjus", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ( {name} )", "step": { + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na" + } + }, "user": { "data": { "ip_address": "IP aadress" diff --git a/homeassistant/components/devolo_home_network/translations/fr.json b/homeassistant/components/devolo_home_network/translations/fr.json index 50e601bb14a78892551ccc492d08108f1c3c0581..cbf4a881c2bd8b7c30b61bec089f3b02b6f3fbfd 100644 --- a/homeassistant/components/devolo_home_network/translations/fr.json +++ b/homeassistant/components/devolo_home_network/translations/fr.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "home_control": "L'unit\u00e9 centrale devolo Home Control ne fonctionne pas avec cette int\u00e9gration." + "home_control": "L'unit\u00e9 centrale devolo Home Control ne fonctionne pas avec cette int\u00e9gration.", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ( {name} )", "step": { + "reauth_confirm": { + "data": { + "password": "Mot de passe" + } + }, "user": { "data": { "ip_address": "Adresse IP" diff --git a/homeassistant/components/devolo_home_network/translations/he.json b/homeassistant/components/devolo_home_network/translations/he.json index 6bb4c9a7ed37b2c456cb40b7b223495484870b0c..5f1e6dbe49aefa4a2ad864c5a868648fa9f27057 100644 --- a/homeassistant/components/devolo_home_network/translations/he.json +++ b/homeassistant/components/devolo_home_network/translations/he.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", @@ -9,6 +10,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + }, "user": { "data": { "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea IP" diff --git a/homeassistant/components/devolo_home_network/translations/hu.json b/homeassistant/components/devolo_home_network/translations/hu.json index dfae08312dfb5912d27b5e7b3ae0df53a48a98ad..6e202527929df8ecaa18e445f285c75a9eb0a13b 100644 --- a/homeassistant/components/devolo_home_network/translations/hu.json +++ b/homeassistant/components/devolo_home_network/translations/hu.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "home_control": "A devolo Home Control k\u00f6zponti egys\u00e9g nem m\u0171k\u00f6dik ezzel az integr\u00e1ci\u00f3val." + "home_control": "A devolo Home Control k\u00f6zponti egys\u00e9g nem m\u0171k\u00f6dik ezzel az integr\u00e1ci\u00f3val.", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + } + }, "user": { "data": { "ip_address": "IP c\u00edm" diff --git a/homeassistant/components/devolo_home_network/translations/id.json b/homeassistant/components/devolo_home_network/translations/id.json index 0950f6a2711823db40317d1d6bdfcaf92595e4e3..5e6d5cd67d5054106f43fc41cb973089b19d7003 100644 --- a/homeassistant/components/devolo_home_network/translations/id.json +++ b/homeassistant/components/devolo_home_network/translations/id.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Perangkat sudah dikonfigurasi", - "home_control": "Unit Central devolo Home Control tidak berfungsi dengan integrasi ini." + "home_control": "Unit Central devolo Home Control tidak berfungsi dengan integrasi ini.", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + } + }, "user": { "data": { "ip_address": "Alamat IP" diff --git a/homeassistant/components/devolo_home_network/translations/it.json b/homeassistant/components/devolo_home_network/translations/it.json index 118ad0e79c64d1bbc425614432f2533ae5b653b4..9494d34f01ac3ec652960d7269b2bfb7128ea72d 100644 --- a/homeassistant/components/devolo_home_network/translations/it.json +++ b/homeassistant/components/devolo_home_network/translations/it.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", - "home_control": "L'unit\u00e0 centrale devolo Home Control non funziona con questa integrazione." + "home_control": "L'unit\u00e0 centrale devolo Home Control non funziona con questa integrazione.", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "cannot_connect": "Impossibile connettersi", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "Password" + } + }, "user": { "data": { "ip_address": "Indirizzo IP" diff --git a/homeassistant/components/devolo_home_network/translations/ja.json b/homeassistant/components/devolo_home_network/translations/ja.json index 03612804d2dc4070ab91ac3deca66adefee7832d..eea08e9691fba595714522760be94dd31b63abca 100644 --- a/homeassistant/components/devolo_home_network/translations/ja.json +++ b/homeassistant/components/devolo_home_network/translations/ja.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", - "home_control": "devolo Home Control Central Unit\u306f\u3001\u3053\u306e\u7d71\u5408\u3067\u306f\u52d5\u4f5c\u3057\u307e\u305b\u3093\u3002" + "home_control": "devolo Home Control Central Unit\u306f\u3001\u3053\u306e\u7d71\u5408\u3067\u306f\u52d5\u4f5c\u3057\u307e\u305b\u3093\u3002", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" }, "error": { "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + } + }, "user": { "data": { "ip_address": "IP\u30a2\u30c9\u30ec\u30b9" diff --git a/homeassistant/components/devolo_home_network/translations/nb.json b/homeassistant/components/devolo_home_network/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..ad353f5c134f2038c5dd331251234762696f1a5d --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/nb.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Passord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/nl.json b/homeassistant/components/devolo_home_network/translations/nl.json index af3d6e55365d495b3471c159672e8b9b63e48d7f..e531c85c89128996bb8859a964b4432fb994fcf6 100644 --- a/homeassistant/components/devolo_home_network/translations/nl.json +++ b/homeassistant/components/devolo_home_network/translations/nl.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Apparaat is al geconfigureerd", - "home_control": "De devolo Home Control Centrale Unit werkt niet met deze integratie." + "home_control": "De devolo Home Control Centrale Unit werkt niet met deze integratie.", + "reauth_successful": "Herauthenticatie geslaagd" }, "error": { "cannot_connect": "Kan geen verbinding maken", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "Wachtwoord" + } + }, "user": { "data": { "ip_address": "IP-adres" diff --git a/homeassistant/components/devolo_home_network/translations/no.json b/homeassistant/components/devolo_home_network/translations/no.json index 405434abc4a5dfed7fcf29b73845e7b09e564e92..e7554b9aa9105393688b45b5709406845ca58e9c 100644 --- a/homeassistant/components/devolo_home_network/translations/no.json +++ b/homeassistant/components/devolo_home_network/translations/no.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "home_control": "Devolo Home Control Central Unit fungerer ikke med denne integrasjonen." + "home_control": "Devolo Home Control Central Unit fungerer ikke med denne integrasjonen.", + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ( {name} )", "step": { + "reauth_confirm": { + "data": { + "password": "Passord" + } + }, "user": { "data": { "ip_address": "IP adresse" diff --git a/homeassistant/components/devolo_home_network/translations/pl.json b/homeassistant/components/devolo_home_network/translations/pl.json index 4abe26671002d87ab247c65284edb4d5ace6a552..70bcee1ecfc9b27386bf5eb32f20f8dda2a4c8ff 100644 --- a/homeassistant/components/devolo_home_network/translations/pl.json +++ b/homeassistant/components/devolo_home_network/translations/pl.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", - "home_control": "Ta jednostka devolo Home Control Central nie wsp\u00f3\u0142pracuje z t\u0105 integracj\u0105." + "home_control": "Ta jednostka devolo Home Control Central nie wsp\u00f3\u0142pracuje z t\u0105 integracj\u0105.", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "Has\u0142o" + } + }, "user": { "data": { "ip_address": "Adres IP" diff --git a/homeassistant/components/devolo_home_network/translations/pt-BR.json b/homeassistant/components/devolo_home_network/translations/pt-BR.json index 94a1f632d788e1a3214b3240e8ff7d83d497845c..9eae8cedf0f854af492cef8f8a8052568272eefa 100644 --- a/homeassistant/components/devolo_home_network/translations/pt-BR.json +++ b/homeassistant/components/devolo_home_network/translations/pt-BR.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", - "home_control": "A Unidade Central de Home Control Devolo n\u00e3o funciona com esta integra\u00e7\u00e3o." + "home_control": "A Unidade Central de Home Control Devolo n\u00e3o funciona com esta integra\u00e7\u00e3o.", + "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" }, "error": { "cannot_connect": "Falha ao conectar", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "Senha" + } + }, "user": { "data": { "ip_address": "Endere\u00e7o IP" diff --git a/homeassistant/components/devolo_home_network/translations/ru.json b/homeassistant/components/devolo_home_network/translations/ru.json index 4cc909b8816a403481cc052c35a51c3dc9c19fb2..3149b2951c78e5595f37620a35606fe1823d6f74 100644 --- a/homeassistant/components/devolo_home_network/translations/ru.json +++ b/homeassistant/components/devolo_home_network/translations/ru.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", - "home_control": "\u0426\u0435\u043d\u0442\u0440\u0430\u043b\u044c\u043d\u044b\u0439 \u0431\u043b\u043e\u043a \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f devolo Home Control \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u0441 \u044d\u0442\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0435\u0439." + "home_control": "\u0426\u0435\u043d\u0442\u0440\u0430\u043b\u044c\u043d\u044b\u0439 \u0431\u043b\u043e\u043a \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f devolo Home Control \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u0441 \u044d\u0442\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0435\u0439.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + } + }, "user": { "data": { "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441" diff --git a/homeassistant/components/devolo_home_network/translations/sv.json b/homeassistant/components/devolo_home_network/translations/sv.json index 097e9d826b9be7283d975a804c50a083a6378165..9ea453faddec7f58b09f9e97d92324d5129d0a5c 100644 --- a/homeassistant/components/devolo_home_network/translations/sv.json +++ b/homeassistant/components/devolo_home_network/translations/sv.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Enheten \u00e4r redan konfigurerad", - "home_control": "Devolo Home Control Central Unit fungerar inte med denna integration." + "home_control": "Devolo Home Control Central Unit fungerar inte med denna integration.", + "reauth_successful": "\u00c5terautentisering lyckades" }, "error": { "cannot_connect": "Det gick inte att ansluta.", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "L\u00f6senord" + } + }, "user": { "data": { "ip_address": "IP-adress" diff --git a/homeassistant/components/devolo_home_network/translations/tr.json b/homeassistant/components/devolo_home_network/translations/tr.json index 841ae1773cad6d4f3c800de064148cb058f1ee09..def4954dc423ea1b3f05acd1c5094dda63737905 100644 --- a/homeassistant/components/devolo_home_network/translations/tr.json +++ b/homeassistant/components/devolo_home_network/translations/tr.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "home_control": "devolo Ev Kontrol Merkezi Birimi bu entegrasyonla \u00e7al\u0131\u015fmaz." + "home_control": "devolo Ev Kontrol Merkezi Birimi bu entegrasyonla \u00e7al\u0131\u015fmaz.", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "Parola" + } + }, "user": { "data": { "ip_address": "IP Adresi" diff --git a/homeassistant/components/devolo_home_network/translations/zh-Hant.json b/homeassistant/components/devolo_home_network/translations/zh-Hant.json index bccc6aa24e34b36d38aa72fa8069d1e8e19cb3e4..e17f7fc106b9c138aa7755ef182a863b8408edbd 100644 --- a/homeassistant/components/devolo_home_network/translations/zh-Hant.json +++ b/homeassistant/components/devolo_home_network/translations/zh-Hant.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "home_control": "Devolo \u667a\u80fd\u5bb6\u5ead\u7db2\u8def\u88dd\u7f6e\u8207\u6b64\u6574\u5408\u4e0d\u76f8\u5bb9\u3002" + "home_control": "Devolo \u667a\u80fd\u5bb6\u5ead\u7db2\u8def\u88dd\u7f6e\u8207\u6b64\u6574\u5408\u4e0d\u76f8\u5bb9\u3002", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", @@ -10,6 +11,11 @@ }, "flow_title": "{product} ({name})", "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc" + } + }, "user": { "data": { "ip_address": "IP \u4f4d\u5740" diff --git a/homeassistant/components/dexcom/translations/nb.json b/homeassistant/components/dexcom/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/dexcom/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/diaz/manifest.json b/homeassistant/components/diaz/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..50a8d865798cf49fdeef9904b75b7e285ffe410a --- /dev/null +++ b/homeassistant/components/diaz/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "diaz", + "name": "Diaz", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/digital_loggers/manifest.json b/homeassistant/components/digital_loggers/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..b9d0e24c3409a98171a16890d72e7504e26a34a7 --- /dev/null +++ b/homeassistant/components/digital_loggers/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "digital_loggers", + "name": "Digital Loggers", + "integration_type": "virtual", + "supported_by": "wemo" +} diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py index 9728da99a6f64f9c8d989cfcda880b5559cf3636..59c6f7961c238d50d00ecea0bad2013b22c461e7 100644 --- a/homeassistant/components/digital_ocean/binary_sensor.py +++ b/homeassistant/components/digital_ocean/binary_sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -64,6 +63,8 @@ def setup_platform( class DigitalOceanBinarySensor(BinarySensorEntity): """Representation of a Digital Ocean droplet sensor.""" + _attr_attribution = ATTRIBUTION + def __init__(self, do, droplet_id): # pylint: disable=invalid-name """Initialize a new Digital Ocean sensor.""" self._digital_ocean = do @@ -90,7 +91,6 @@ class DigitalOceanBinarySensor(BinarySensorEntity): def extra_state_attributes(self): """Return the state attributes of the Digital Ocean droplet.""" return { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_CREATED_AT: self.data.created_at, ATTR_DROPLET_ID: self.data.id, ATTR_DROPLET_NAME: self.data.name, diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py index da955e221a393bc598e1860388559a2155f935e3..2791d83d6bc1fce6057f031866315b3503bc0f30 100644 --- a/homeassistant/components/digital_ocean/switch.py +++ b/homeassistant/components/digital_ocean/switch.py @@ -7,7 +7,6 @@ from typing import Any import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity -from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -62,6 +61,8 @@ def setup_platform( class DigitalOceanSwitch(SwitchEntity): """Representation of a Digital Ocean droplet switch.""" + _attr_attribution = ATTRIBUTION + def __init__(self, do, droplet_id): # pylint: disable=invalid-name """Initialize a new Digital Ocean sensor.""" self._digital_ocean = do @@ -83,7 +84,6 @@ class DigitalOceanSwitch(SwitchEntity): def extra_state_attributes(self): """Return the state attributes of the Digital Ocean droplet.""" return { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_CREATED_AT: self.data.created_at, ATTR_DROPLET_ID: self.data.id, ATTR_DROPLET_NAME: self.data.name, diff --git a/homeassistant/components/directv/translations/nb.json b/homeassistant/components/directv/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/directv/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index e207755ec24c876b8a563fe4a189d2fb88b9d989..51c69449d222d5f067b5177197173c477f397a3c 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -13,12 +13,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - CONF_TOKEN, -) +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE import homeassistant.helpers.config_validation as cv @@ -29,8 +24,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_IDENTITY = "identity" -ATTRIBUTION = "Data provided by Discogs" - DEFAULT_NAME = "Discogs" ICON_RECORD = "mdi:album" @@ -111,6 +104,8 @@ def setup_platform( class DiscogsSensor(SensorEntity): """Create a new Discogs sensor for a specific type.""" + _attr_attribution = "Data provided by Discogs" + def __init__(self, discogs_data, name, description: SensorEntityDescription): """Initialize the Discogs sensor.""" self.entity_description = description @@ -135,12 +130,10 @@ class DiscogsSensor(SensorEntity): "format": f"{self._attrs['formats'][0]['name']} ({self._attrs['formats'][0]['descriptions'][0]})", "label": self._attrs["labels"][0]["name"], "released": self._attrs["year"], - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_IDENTITY: self._discogs_data["user"], } return { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_IDENTITY: self._discogs_data["user"], } diff --git a/homeassistant/components/discord/translations/nb.json b/homeassistant/components/discord/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/discord/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/discord/translations/no.json b/homeassistant/components/discord/translations/no.json index e8a36e5c79433aba1cf51ab4c01a7336627f1388..414df1519c16e9117daf956f4f39f9f9af503659 100644 --- a/homeassistant/components/discord/translations/no.json +++ b/homeassistant/components/discord/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Tjenesten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 7e03d34e900fa7d2b58882095d4e76065fed14f9..9d05b02000bf1ae5b332751862b48d6691562cc5 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.2"], + "requirements": ["async-upnp-client==0.32.1"], "dependencies": ["ssdp"], "after_dependencies": ["media_source"], "ssdp": [ diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index a07f33d09dd436568c7516120e6fce075cd33a08..98ad81e653a46ea344b9ffc58b87385ed7b10a83 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.2"], + "requirements": ["async-upnp-client==0.32.1"], "dependencies": ["ssdp"], "after_dependencies": ["media_source"], "ssdp": [ diff --git a/homeassistant/components/dlna_dms/translations/bg.json b/homeassistant/components/dlna_dms/translations/bg.json index da5fbf4c01c54eb32f112f8d346c87963eb7f05b..1e65e7f6ae886463494c5756cdbad68b67ee0259 100644 --- a/homeassistant/components/dlna_dms/translations/bg.json +++ b/homeassistant/components/dlna_dms/translations/bg.json @@ -12,7 +12,8 @@ "user": { "data": { "host": "\u0425\u043e\u0441\u0442" - } + }, + "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0437\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435" } } } diff --git a/homeassistant/components/dominos/__init__.py b/homeassistant/components/dominos/__init__.py index ecfe7b65a7da78d8a3e415f97f01a261ca5c6918..37344ed9d4bd9a59ba8449176603ab04c7032717 100644 --- a/homeassistant/components/dominos/__init__.py +++ b/homeassistant/components/dominos/__init__.py @@ -90,8 +90,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: order = DominosOrder(order_info, dominos) entities.append(order) - if entities: - component.add_entities(entities) + component.add_entities(entities) # Return boolean to indicate that initialization was successfully. return True diff --git a/homeassistant/components/doorbird/translations/nb.json b/homeassistant/components/doorbird/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/doorbird/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dooya/manifest.json b/homeassistant/components/dooya/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..aa05d5b5475e2c4c436f20aca6f7a10a898e3630 --- /dev/null +++ b/homeassistant/components/dooya/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "dooya", + "name": "Dooya", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index b086944a4d2b5c45df78a6e3f2b43350a6cba374..e81fb5f801c441b297bb0a175f126a62d7ae1e55 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -6,5 +6,6 @@ "codeowners": ["@Robbie1221", "@frenck"], "config_flow": true, "iot_class": "local_push", + "integration_type": "hub", "loggers": ["dsmr_parser"] } diff --git a/homeassistant/components/dsmr_reader/translations/bg.json b/homeassistant/components/dsmr_reader/translations/bg.json index 1c6120581b097f1e10457e03b391ca0c2212b8f7..68c75f114a61b2d3b82198abdac1d66b708406dc 100644 --- a/homeassistant/components/dsmr_reader/translations/bg.json +++ b/homeassistant/components/dsmr_reader/translations/bg.json @@ -3,5 +3,10 @@ "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." } + }, + "issues": { + "deprecated_yaml": { + "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 DSMR Reader \u0441\u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u0432\u0430" + } } } \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/translations/ca.json b/homeassistant/components/dsmr_reader/translations/ca.json index cc92e3ec9f158ee8fdd0054531f0892f849b504a..24cd0ce614ba64ef45513c11ddaa9bd6f3e0986c 100644 --- a/homeassistant/components/dsmr_reader/translations/ca.json +++ b/homeassistant/components/dsmr_reader/translations/ca.json @@ -2,6 +2,17 @@ "config": { "abort": { "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + }, + "step": { + "confirm": { + "description": "Assegura't de configurar les fonts de dades de 'split topic' de DSMR Reader." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "La configuraci\u00f3 de Lector DSMR mitjan\u00e7ant YAML s'est\u00e0 eliminant.\n\nLa configuraci\u00f3 YAML existent s'ha importat autom\u00e0ticament a la interf\u00edcie d'usuari.\n\nElimina la configuraci\u00f3 YAML de Lector DSMR del fitxer configuration.yaml i reinicia Home Assistant per solucionar aquest problema.", + "title": "La configuraci\u00f3 YAML de DSMR Reader est\u00e0 sent eliminada" } } } \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/translations/el.json b/homeassistant/components/dsmr_reader/translations/el.json new file mode 100644 index 0000000000000000000000000000000000000000..34da88de0ff1cba0207489648d2da746a4fb2771 --- /dev/null +++ b/homeassistant/components/dsmr_reader/translations/el.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0388\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03c4\u03b5\u03af. \u039c\u03cc\u03bd\u03bf \u03bc\u03af\u03b1 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae." + }, + "step": { + "confirm": { + "description": "\u0392\u03b5\u03b2\u03b1\u03b9\u03c9\u03b8\u03b5\u03af\u03c4\u03b5 \u03cc\u03c4\u03b9 \u03ad\u03c7\u03b5\u03c4\u03b5 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03b9 \u03c4\u03b9\u03c2 \u03c0\u03b1\u03c1\u03b1\u03bc\u03ad\u03c4\u03c1\u03bf\u03c5\u03c2 \u03c4\u03c9\u03bd \u03c0\u03b7\u03b3\u03ce\u03bd \u03b4\u03b5\u03b4\u03bf\u03bc\u03ad\u03bd\u03c9\u03bd \u00ab\u03b4\u03b9\u03b1\u03af\u03c1\u03b5\u03c3\u03b7 \u03b8\u03ad\u03bc\u03b1\u03c4\u03bf\u03c2\u00bb \u03c3\u03c4\u03bf\u03bd \u0391\u03bd\u03b1\u03b3\u03bd\u03ce\u03c3\u03c4\u03b7 DSMR." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "\u0397 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03c0\u03c1\u03bf\u03b3\u03c1\u03ac\u03bc\u03bc\u03b1\u03c4\u03bf\u03c2 \u03b1\u03bd\u03ac\u03b3\u03bd\u03c9\u03c3\u03b7\u03c2 DSMR \u03bc\u03b5 \u03c7\u03c1\u03ae\u03c3\u03b7 YAML \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03b5\u03af\u03c4\u03b1\u03b9. \n\n \u0397 \u03c5\u03c0\u03ac\u03c1\u03c7\u03bf\u03c5\u03c3\u03b1 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03ae \u03c3\u03b1\u03c2 YAML \u03ad\u03c7\u03b5\u03b9 \u03b5\u03b9\u03c3\u03b1\u03c7\u03b8\u03b5\u03af \u03b1\u03c5\u03c4\u03cc\u03bc\u03b1\u03c4\u03b1 \u03c3\u03c4\u03b7 \u03b4\u03b9\u03b5\u03c0\u03b1\u03c6\u03ae \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7. \n\n \u039a\u03b1\u03c4\u03b1\u03c1\u03b3\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 YAML \u03c4\u03bf\u03c5 DSMR Reader \u03b1\u03c0\u03cc \u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf configuration.yaml \u03ba\u03b1\u03b9 \u03b5\u03c0\u03b1\u03bd\u03b5\u03ba\u03ba\u03b9\u03bd\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf Home Assistant \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03bf\u03c1\u03b8\u03ce\u03c3\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u03c0\u03c1\u03cc\u03b2\u03bb\u03b7\u03bc\u03b1.", + "title": "\u0397 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03c0\u03c1\u03bf\u03b3\u03c1\u03ac\u03bc\u03bc\u03b1\u03c4\u03bf\u03c2 \u03b1\u03bd\u03ac\u03b3\u03bd\u03c9\u03c3\u03b7\u03c2 DSMR \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03b5\u03af\u03c4\u03b1\u03b9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/translations/et.json b/homeassistant/components/dsmr_reader/translations/et.json new file mode 100644 index 0000000000000000000000000000000000000000..39466fb688a879f303b1071f3433f5564e3650a4 --- /dev/null +++ b/homeassistant/components/dsmr_reader/translations/et.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks sidumine." + }, + "step": { + "confirm": { + "description": "Veenduge, et DSMR Readeris on konfigureeritud andmeallikad \"jagatud teema\"." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "DSMR Readeri konfigureerimine YAML-i abil eemaldatakse. \n\n Teie olemasolev YAML-i konfiguratsioon imporditi kasutajaliidesesse automaatselt. \n\n Eemaldage DSMR Readeri YAML-i konfiguratsioon failist configuration.yaml ja taask\u00e4ivitage selle probleemi lahendamiseks Home Assistant.", + "title": "DSMR lugeja konfiguratsioon eemaldatakse" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/translations/he.json b/homeassistant/components/dsmr_reader/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..d0c3523da94e2aa86d554d911746a1e50a4fa12e --- /dev/null +++ b/homeassistant/components/dsmr_reader/translations/he.json @@ -0,0 +1,7 @@ +{ + "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." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/translations/id.json b/homeassistant/components/dsmr_reader/translations/id.json new file mode 100644 index 0000000000000000000000000000000000000000..9102b5b202ce91524dc4e7710579ad30c4278193 --- /dev/null +++ b/homeassistant/components/dsmr_reader/translations/id.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." + }, + "step": { + "confirm": { + "description": "Pastikan untuk mengkonfigurasi sumber data 'topik terpisah' di DSMR Reader." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Proses konfigurasi Integrasi DSMR Reader lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Integrasi DSMR Reader dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi DSMR Reader dalam proses penghapusan" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/translations/it.json b/homeassistant/components/dsmr_reader/translations/it.json new file mode 100644 index 0000000000000000000000000000000000000000..55e13fc78bb65e762fd1429ff526583892db02f3 --- /dev/null +++ b/homeassistant/components/dsmr_reader/translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "step": { + "confirm": { + "description": "Assicurarsi di configurare le origini dati \"split topic\" in DSMR Reader." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "La configurazione di DSMR Reader tramite YAML \u00e8 stata rimossa. \n\nLa tua configurazione YAML esistente \u00e8 stata importata automaticamente nell'interfaccia utente. \n\nRimuovere la configurazione YAML di DSMR Reader dal file configuration.yaml e riavviare Home Assistant per risolvere questo problema.", + "title": "La configurazione del lettore DSMR \u00e8 stata rimossa" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/translations/ja.json b/homeassistant/components/dsmr_reader/translations/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..cf3ac93acad0b85803b26711fbd8091ba27a03ae --- /dev/null +++ b/homeassistant/components/dsmr_reader/translations/ja.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u8a2d\u5b9a\u3067\u304d\u308b\u306e\u306f1\u3064\u3060\u3051\u3067\u3059\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/translations/nb.json b/homeassistant/components/dsmr_reader/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..353183280b640384aade972bcf719fd3c613defd --- /dev/null +++ b/homeassistant/components/dsmr_reader/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Allerede konfigurert. Kun \u00e9n enkelt konfigurasjon er mulig." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/translations/pl.json b/homeassistant/components/dsmr_reader/translations/pl.json new file mode 100644 index 0000000000000000000000000000000000000000..71cc2bdd4f7771836fe9a0d870794ecc638d344f --- /dev/null +++ b/homeassistant/components/dsmr_reader/translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "step": { + "confirm": { + "description": "Pami\u0119taj, aby skonfigurowa\u0107 \u017ar\u00f3d\u0142a danych \u201esplit topic\u201d w DSMR Reader." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Konfiguracja DSMR Reader przy u\u017cyciu YAML zostanie usuni\u0119ta. \n\nTwoja istniej\u0105ca konfiguracja YAML zosta\u0142a automatycznie zaimportowana do interfejsu u\u017cytkownika. \n\nUsu\u0144 konfiguracj\u0119 YAML z pliku configuration.yaml i uruchom ponownie Home Assistanta, aby rozwi\u0105za\u0107 ten problem.", + "title": "Konfiguracja YAML dla DSMR Reader zostanie usuni\u0119ta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/translations/pt-BR.json b/homeassistant/components/dsmr_reader/translations/pt-BR.json new file mode 100644 index 0000000000000000000000000000000000000000..5980921027a2f24fdd6f8dfb6c1c51b9c5dc1535 --- /dev/null +++ b/homeassistant/components/dsmr_reader/translations/pt-BR.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "J\u00e1 configurado. Apenas uma configura\u00e7\u00e3o \u00e9 poss\u00edvel." + }, + "step": { + "confirm": { + "description": "Certifique-se de configurar as fontes de dados 'split topic' no DSMR Reader." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "A configura\u00e7\u00e3o do DSMR Reader usando YAML est\u00e1 sendo removida. \n\n Sua configura\u00e7\u00e3o YAML existente foi importada para a interface do usu\u00e1rio automaticamente. \n\n Remova a configura\u00e7\u00e3o YAML do leitor DSMR do arquivo configuration.yaml e reinicie o Home Assistant para corrigir esse problema.", + "title": "A configura\u00e7\u00e3o do Leitor DSMR est\u00e1 sendo removida" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/translations/sv.json b/homeassistant/components/dsmr_reader/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..4434d1f3f96114fe0a40128a8f0ec44991c8a536 --- /dev/null +++ b/homeassistant/components/dsmr_reader/translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Redan konfigurerad. Endast en konfiguration m\u00f6jlig." + }, + "step": { + "confirm": { + "description": "Se till att konfigurera datak\u00e4llorna f\u00f6r \"delat \u00e4mne\" i DSMR Reader." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Konfigurering av DSMR Reader med YAML tas bort. \n\n Din befintliga YAML-konfiguration har automatiskt importerats till anv\u00e4ndargr\u00e4nssnittet. \n\n Ta bort DSMR Reader YAML-konfigurationen fr\u00e5n filen configuration.yaml och starta om Home Assistant f\u00f6r att \u00e5tg\u00e4rda problemet.", + "title": "Konfigurationen av DSMR-l\u00e4saren tas bort" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dsmr_reader/translations/tr.json b/homeassistant/components/dsmr_reader/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..aca2ecaf6fbd06561f9c3412103522fa9d9b06bc --- /dev/null +++ b/homeassistant/components/dsmr_reader/translations/tr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "step": { + "confirm": { + "description": "DSMR Reader'da 'b\u00f6l\u00fcnm\u00fc\u015f konu' veri kaynaklar\u0131n\u0131 yap\u0131land\u0131rd\u0131\u011f\u0131n\u0131zdan emin olun." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "DSMR Reader'\u0131n YAML kullan\u0131larak yap\u0131land\u0131r\u0131lmas\u0131 kald\u0131r\u0131l\u0131yor. \n\n Mevcut YAML yap\u0131land\u0131rman\u0131z otomatik olarak kullan\u0131c\u0131 aray\u00fcz\u00fcne aktar\u0131ld\u0131. \n\n DSMR Reader YAML yap\u0131land\u0131rmas\u0131n\u0131 configuration.yaml dosyan\u0131zdan kald\u0131r\u0131n ve bu sorunu gidermek i\u00e7in Home Assistant'\u0131 yeniden ba\u015flat\u0131n.", + "title": "DSMR Okuyucu yap\u0131land\u0131rmas\u0131 kald\u0131r\u0131l\u0131yor" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index cf65c18e91cb5e015944a8a2d6e1bbf6ab5b1a0f..6b9d47aecb361e1014c686d0cdbae9b088e15697 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -14,7 +14,7 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES +from homeassistant.const import CONF_NAME, TIME_MINUTES from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -29,8 +29,6 @@ ATTR_DUE_IN = "Due in" ATTR_DUE_AT = "Due at" ATTR_NEXT_UP = "Later Bus" -ATTRIBUTION = "Data provided by data.dublinked.ie" - CONF_STOP_ID = "stopid" CONF_ROUTE = "route" @@ -79,6 +77,8 @@ def setup_platform( class DublinPublicTransportSensor(SensorEntity): """Implementation of an Dublin public transport sensor.""" + _attr_attribution = "Data provided by data.dublinked.ie" + def __init__(self, data, stop, route, name): """Initialize the sensor.""" self.data = data @@ -111,7 +111,6 @@ class DublinPublicTransportSensor(SensorEntity): ATTR_DUE_AT: self._times[0][ATTR_DUE_AT], ATTR_STOP_ID: self._stop, ATTR_ROUTE: self._times[0][ATTR_ROUTE], - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_NEXT_UP: next_up, } diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 1436b41603196a73878b241a161c921b23c9b0b3..d6b3f6c2a0dbea2300e5fc65a55fa19279e4248e 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -22,7 +22,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -31,7 +31,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by DWD" ATTR_REGION_NAME = "region_name" ATTR_REGION_ID = "region_id" ATTR_LAST_UPDATE = "last_update" @@ -108,6 +107,8 @@ def setup_platform( class DwdWeatherWarningsSensor(SensorEntity): """Representation of a DWD-Weather-Warnings sensor.""" + _attr_attribution = "Data provided by DWD" + def __init__( self, api, @@ -130,7 +131,6 @@ class DwdWeatherWarningsSensor(SensorEntity): def extra_state_attributes(self): """Return the state attributes of the DWD-Weather-Warnings.""" data = { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_REGION_NAME: self._api.api.warncell_name, ATTR_REGION_ID: self._api.api.warncell_id, ATTR_LAST_UPDATE: self._api.api.last_update, diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index c1814307d1cbabed2b0b2d85d98e53a8b87da949..b4b8285cbb011aaca77f95abaafd58f01c57d7f5 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -31,8 +31,7 @@ def async_setup_entry_base( added_entities = [] for device in devices: added_entities.append(entity_from_device(device, bridge)) - if added_entities: - async_add_entities(added_entities) + async_add_entities(added_entities) bridge.register_add_devices(platform, async_add_entities_platform) diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index 57a248d70e96cc8fd84a8a34a3a8a22058354aec..a695a38bb4b29281777b55afac59f7ef7c639998 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -5,9 +5,9 @@ import logging from aioeafm import get_station import async_timeout -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, LENGTH_METERS +from homeassistant.const import LENGTH_METERS from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType @@ -90,7 +90,11 @@ async def async_setup_entry( class Measurement(CoordinatorEntity, SensorEntity): """A gauge at a flood monitoring station.""" - attribution = "This uses Environment Agency flood and river level data from the real-time data API" + _attr_attribution = ( + "This uses Environment Agency flood and river level data " + "from the real-time data API" + ) + _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, coordinator, key): """Initialise the gauge with a data instance and station.""" @@ -165,11 +169,6 @@ class Measurement(CoordinatorEntity, SensorEntity): return None return UNIT_MAPPING.get(measure["unit"], measure["unitName"]) - @property - def extra_state_attributes(self): - """Return the sensor specific state attributes.""" - return {ATTR_ATTRIBUTION: self.attribution} - @property def native_value(self): """Return the current sensor value.""" diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index 7204dbf8de2c7435c59c0ab627721a941efd44d2..31a1e753fc63b76c156e0dd74fa511d5340476f9 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -5,13 +5,21 @@ from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, Ecobee, ExpiredTokenE import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle -from .const import _LOGGER, CONF_REFRESH_TOKEN, DATA_ECOBEE_CONFIG, DOMAIN, PLATFORMS +from .const import ( + _LOGGER, + ATTR_CONFIG_ENTRY_ID, + CONF_REFRESH_TOKEN, + DATA_ECOBEE_CONFIG, + DATA_HASS_CONFIG, + DOMAIN, + PLATFORMS, +) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) @@ -30,7 +38,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: migrating from the old ecobee integration. Otherwise, the user will have to continue setting up the integration via the config flow. """ + hass.data[DATA_ECOBEE_CONFIG] = config.get(DOMAIN, {}) + hass.data[DATA_HASS_CONFIG] = config if not hass.config_entries.async_entries(DOMAIN) and hass.data[DATA_ECOBEE_CONFIG]: # No config entry exists and configuration.yaml config exists, trigger the import flow. @@ -63,6 +73,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + {CONF_NAME: entry.title, ATTR_CONFIG_ENTRY_ID: entry.entry_id}, + hass.data[DATA_HASS_CONFIG], + ) + ) + return True diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index b4e0c485e4550ceb19d686dcafd1693ca7ee4adc..4a318f2be3c47c3bde10723adb0de3f63bf1c46e 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -20,8 +20,9 @@ _LOGGER = logging.getLogger(__package__) DOMAIN = "ecobee" DATA_ECOBEE_CONFIG = "ecobee_config" +DATA_HASS_CONFIG = "ecobee_hass_config" +ATTR_CONFIG_ENTRY_ID = "entry_id" -CONF_INDEX = "index" CONF_REFRESH_TOKEN = "refresh_token" ECOBEE_MODEL_TO_NAME = { diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py index a8f53a027b370cdd738c768b8e1011c9e6d0e82d..75d1316f0e3cdbdd2475bd398ca2ae0cd7bf44f4 100644 --- a/homeassistant/components/ecobee/notify.py +++ b/homeassistant/components/ecobee/notify.py @@ -1,31 +1,33 @@ """Support for Ecobee Send Message service.""" -import voluptuous as vol -from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService -import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from .const import CONF_INDEX, DOMAIN - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Optional(CONF_INDEX, default=0): cv.positive_int} -) +from .const import DOMAIN def get_service(hass, config, discovery_info=None): """Get the Ecobee notification service.""" + if discovery_info is None: + return None + data = hass.data[DOMAIN] - index = config.get(CONF_INDEX) - return EcobeeNotificationService(data, index) + return EcobeeNotificationService(data.ecobee) class EcobeeNotificationService(BaseNotificationService): """Implement the notification service for the Ecobee thermostat.""" - def __init__(self, data, thermostat_index): + def __init__(self, ecobee): """Initialize the service.""" - self.data = data - self.thermostat_index = thermostat_index + self.ecobee = ecobee def send_message(self, message="", **kwargs): """Send a message.""" - self.data.ecobee.send_message(self.thermostat_index, message) + targets = kwargs.get(ATTR_TARGET) + + if not targets: + raise ValueError("Missing required argument: target") + + for target in targets: + thermostat_index = int(target) + self.ecobee.send_message(thermostat_index, message) diff --git a/homeassistant/components/econet/translations/bg.json b/homeassistant/components/econet/translations/bg.json index 3468d506903d9f51df223cb98d621cdf79b2b6ff..637413ad06d69d26791884b88de287c36f297a72 100644 --- a/homeassistant/components/econet/translations/bg.json +++ b/homeassistant/components/econet/translations/bg.json @@ -7,6 +7,7 @@ "step": { "user": { "data": { + "email": "\u0418\u043c\u0435\u0439\u043b", "password": "\u041f\u0430\u0440\u043e\u043b\u0430" } } diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index a644cd3ca7a910ced29387b5678293a2d26b82ee..200ae1d73fc4c27d941284b49d4f44d142609190 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -27,8 +27,6 @@ from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, POWER_WATT, - PRECIPITATION_INCHES_PER_HOUR, - PRECIPITATION_MILLIMETERS_PER_HOUR, PRESSURE_HPA, PRESSURE_INHG, SPEED_KILOMETERS_PER_HOUR, @@ -36,10 +34,12 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, UV_INDEX, + UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .const import DOMAIN from .entity import EcowittEntity @@ -156,13 +156,15 @@ ECOWITT_SENSORS_MAPPING: Final = { ), EcoWittSensorTypes.RAIN_RATE_MM: SensorEntityDescription( key="RAIN_RATE_MM", - native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), EcoWittSensorTypes.RAIN_RATE_INCHES: SensorEntityDescription( key="RAIN_RATE_INCHES", - native_unit_of_measurement=PRECIPITATION_INCHES_PER_HOUR, + native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: SensorEntityDescription( key="LIGHTNING_DISTANCE_KM", @@ -216,9 +218,9 @@ async def async_setup_entry( return # Ignore metrics that are not supported by the user's locale - if sensor.stype in _METRIC and not hass.config.units.is_metric: + if sensor.stype in _METRIC and hass.config.units is not METRIC_SYSTEM: return - if sensor.stype in _IMPERIAL and hass.config.units.is_metric: + if sensor.stype in _IMPERIAL and hass.config.units is not US_CUSTOMARY_SYSTEM: return mapping = ECOWITT_SENSORS_MAPPING[sensor.stype] diff --git a/homeassistant/components/ecowitt/translations/he.json b/homeassistant/components/ecowitt/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..822dcf2be1456151b3d317b706f80946894ec6c3 --- /dev/null +++ b/homeassistant/components/ecowitt/translations/he.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecowitt/translations/nb.json b/homeassistant/components/ecowitt/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/ecowitt/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecowitt/translations/nl.json b/homeassistant/components/ecowitt/translations/nl.json index 1090e946378c0bc76724c2355cda377e5ce8cf4d..112651607f4ee7e5aaa4d71d1e346dcc4d427630 100644 --- a/homeassistant/components/ecowitt/translations/nl.json +++ b/homeassistant/components/ecowitt/translations/nl.json @@ -3,6 +3,11 @@ "error": { "invalid_port": "Poort wordt al gebruikt.", "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "description": "Weet u zeker dat u Ecowitt wilt instellen?" + } } } } \ No newline at end of file diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index cac35a10152d0b3127f79461758d930c05f0908c..4a8e209d3a0cb13264dba8f95d86e4dc3a43e3f1 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -3,7 +3,7 @@ "name": "EDL21", "documentation": "https://www.home-assistant.io/integrations/edl21", "requirements": ["pysml==0.0.8"], - "codeowners": ["@mtdcr"], + "codeowners": [], "iot_class": "local_push", "loggers": ["sml"] } diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index cfdbbd01a2ed63cc17321c6358b6b563c12ac764..c598827d244dee4520fb9bc4f8bd78503db636de 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -223,9 +223,17 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), # C=81: Angles # D=7: Instantaneous value + # E=1: U(L2) x U(L1) + # E=2: U(L3) x U(L1) # E=4: U(L1) x I(L1) # E=15: U(L2) x I(L2) # E=26: U(L3) x I(L3) + SensorEntityDescription( + key="1-0:81.7.1*255", name="U(L2)/U(L1) phase angle", icon="mdi:sine-wave" + ), + SensorEntityDescription( + key="1-0:81.7.2*255", name="U(L3)/U(L1) phase angle", icon="mdi:sine-wave" + ), SensorEntityDescription( key="1-0:81.7.4*255", name="U(L1)/I(L1) phase angle", icon="mdi:sine-wave" ), @@ -273,9 +281,13 @@ class EDL21: _OBIS_BLACKLIST = { # C=96: Electricity-related service entries - "1-0:96.50.1*1", # Manufacturer specific - "1-0:96.90.2*1", # Manufacturer specific - "1-0:96.90.2*2", # Manufacturer specific + "1-0:96.50.1*1", # Manufacturer specific EFR SGM-C4 Hardware version + "1-0:96.50.1*4", # Manufacturer specific EFR SGM-C4 Hardware version + "1-0:96.50.4*4", # Manufacturer specific EFR SGM-C4 Parameters version + "1-0:96.90.2*1", # Manufacturer specific EFR SGM-C4 Firmware Checksum + "1-0:96.90.2*2", # Manufacturer specific EFR SGM-C4 Firmware Checksum + # C=97: Electricity-related service entries + "1-0:97.97.0*0", # Manufacturer specific EFR SGM-C4 Error register # A=129: Manufacturer specific "129-129:199.130.3*255", # Iskraemeco: Manufacturer "129-129:199.130.5*255", # Iskraemeco: Public Key diff --git a/homeassistant/components/efergy/translations/nb.json b/homeassistant/components/efergy/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/efergy/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/efergy/translations/no.json b/homeassistant/components/efergy/translations/no.json index 4a109ab8fa90d2a93a8127799aa4824c7e940cb0..29aeb4021915bc3486ef3584f5cd593a9d8a38f0 100644 --- a/homeassistant/components/efergy/translations/no.json +++ b/homeassistant/components/efergy/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index c1833b222dfaa236c751ecd8377684082532130f..4f97b99b2e7975c7a3e10e9f798ba9d71d855c62 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -2,7 +2,7 @@ "domain": "eight_sleep", "name": "Eight Sleep", "documentation": "https://www.home-assistant.io/integrations/eight_sleep", - "requirements": ["pyeight==0.3.0"], + "requirements": ["pyeight==0.3.2"], "codeowners": ["@mezz64", "@raman325"], "iot_class": "cloud_polling", "loggers": ["pyeight"], diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index b184cd2496f951bfe706167459e569f66ca88196..b07865d8591e09e4268fd62c1e8b173945d593fb 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -146,7 +146,7 @@ def _get_breakdown_percent( """Get a breakdown percent.""" try: return round((attr["breakdown"][key] / denominator) * 100, 2) - except ZeroDivisionError: + except (ZeroDivisionError, KeyError): return 0 diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 91e6d09d973f6326178408aa6275391e0fbdaee4..8311d6f7fb5b686bc1d3344f156d41d906e8231a 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -7,5 +7,6 @@ "zeroconf": ["_elg._tcp.local."], "codeowners": ["@frenck"], "quality_scale": "platinum", - "iot_class": "local_polling" + "iot_class": "local_polling", + "integration_type": "device" } diff --git a/homeassistant/components/elkm1/translations/nb.json b/homeassistant/components/elkm1/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/elkm1/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/nb.json b/homeassistant/components/elmax/translations/nb.json index f126937f2fea10bb1057502ded81f90a3cd313b9..531c356bd24e9a9d1e9099dfb32deb9cb6ac30f5 100644 --- a/homeassistant/components/elmax/translations/nb.json +++ b/homeassistant/components/elmax/translations/nb.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "Enheten er allerede konfigurert" }, + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/emonitor/translations/bg.json b/homeassistant/components/emonitor/translations/bg.json index e8940bef26a961dd7e003bd398d7d2e3ced01dbc..6290f483074f5b9a357cabc4e592a396dbfd44c8 100644 --- a/homeassistant/components/emonitor/translations/bg.json +++ b/homeassistant/components/emonitor/translations/bg.json @@ -1,5 +1,22 @@ { "config": { - "flow_title": "{name}" + "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", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 {name} ({host})?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/emonitor/translations/nb.json b/homeassistant/components/emonitor/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/emonitor/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index bc7903203c4657c2814909f718ad391b0a4703fe..339c0c638e23aa661d177b20424b438f0ddc2670 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -87,13 +87,13 @@ class BatterySourceType(TypedDict): class GasSourceType(TypedDict): - """Dictionary holding the source of gas storage.""" + """Dictionary holding the source of gas consumption.""" type: Literal["gas"] stat_energy_from: str - # statistic_id of costs ($) incurred from the energy meter + # statistic_id of costs ($) incurred from the gas meter # If set to None and entity_energy_price or number_energy_price are configured, # an EnergyCostSensor will be automatically created stat_cost: str | None @@ -103,7 +103,26 @@ class GasSourceType(TypedDict): number_energy_price: float | None # Price for energy ($/m³) -SourceType = Union[GridSourceType, SolarSourceType, BatterySourceType, GasSourceType] +class WaterSourceType(TypedDict): + """Dictionary holding the source of water consumption.""" + + type: Literal["water"] + + stat_energy_from: str + + # statistic_id of costs ($) incurred from the water meter + # If set to None and entity_energy_price or number_energy_price are configured, + # an EnergyCostSensor will be automatically created + stat_cost: str | None + + # Used to generate costs if stat_cost is set to None + entity_energy_price: str | None # entity_id of an entity providing price ($/m³) + number_energy_price: float | None # Price for energy ($/m³) + + +SourceType = Union[ + GridSourceType, SolarSourceType, BatterySourceType, GasSourceType, WaterSourceType +] class DeviceConsumption(TypedDict): @@ -221,6 +240,15 @@ GAS_SOURCE_SCHEMA = vol.Schema( vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), } ) +WATER_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("type"): "water", + vol.Required("stat_energy_from"): str, + vol.Optional("stat_cost"): vol.Any(str, None), + vol.Optional("entity_energy_price"): vol.Any(str, None), + vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + } +) def check_type_limits(value: list[SourceType]) -> list[SourceType]: @@ -243,6 +271,7 @@ ENERGY_SOURCE_SCHEMA = vol.All( "solar": SOLAR_SOURCE_SCHEMA, "battery": BATTERY_SOURCE_SCHEMA, "gas": GAS_SOURCE_SCHEMA, + "water": WATER_SOURCE_SCHEMA, }, ) ] diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index db156a2d6cc0e0b51a2e34aed8ac9cdaf3b984be..71e385f2fec2b8d4881831ab04771cff550ae196 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import copy from dataclasses import dataclass import logging @@ -17,11 +18,12 @@ from homeassistant.components.sensor import ( from homeassistant.components.sensor.recorder import reset_detected from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, - ENERGY_KILO_WATT_HOUR, - ENERGY_MEGA_WATT_HOUR, - ENERGY_WATT_HOUR, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, + VOLUME_GALLONS, + VOLUME_LITERS, + UnitOfEnergy, + UnitOfVolume, ) from homeassistant.core import ( HomeAssistant, @@ -34,18 +36,35 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import unit_conversion import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import METRIC_SYSTEM from .const import DOMAIN from .data import EnergyManager, async_get_manager -SUPPORTED_STATE_CLASSES = [ +SUPPORTED_STATE_CLASSES = { SensorStateClass.MEASUREMENT, SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, -] -VALID_ENERGY_UNITS = [ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR, ENERGY_MEGA_WATT_HOUR] -VALID_ENERGY_UNITS_GAS = [VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS] + VALID_ENERGY_UNITS +} +VALID_ENERGY_UNITS: set[str] = { + UnitOfEnergy.WATT_HOUR, + UnitOfEnergy.KILO_WATT_HOUR, + UnitOfEnergy.MEGA_WATT_HOUR, + UnitOfEnergy.GIGA_JOULE, +} +VALID_ENERGY_UNITS_GAS = { + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, + *VALID_ENERGY_UNITS, +} +VALID_VOLUME_UNITS_WATER = { + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, + VOLUME_GALLONS, + VOLUME_LITERS, +} _LOGGER = logging.getLogger(__name__) @@ -64,7 +83,7 @@ async def async_setup_platform( class SourceAdapter: """Adapter to allow sources and their flows to be used as sensors.""" - source_type: Literal["grid", "gas"] + source_type: Literal["grid", "gas", "water"] flow_type: Literal["flow_from", "flow_to", None] stat_energy_key: Literal["stat_energy_from", "stat_energy_to"] total_money_key: Literal["stat_cost", "stat_compensation"] @@ -97,6 +116,14 @@ SOURCE_ADAPTERS: Final = ( "Cost", "cost", ), + SourceAdapter( + "water", + None, + "stat_energy_from", + "stat_cost", + "Cost", + "cost", + ), ) @@ -235,6 +262,22 @@ class EnergyCostSensor(SensorEntity): @callback def _update_cost(self) -> None: """Update incurred costs.""" + if self._adapter.source_type == "grid": + valid_units = VALID_ENERGY_UNITS + default_price_unit: str | None = UnitOfEnergy.KILO_WATT_HOUR + + elif self._adapter.source_type == "gas": + valid_units = VALID_ENERGY_UNITS_GAS + # No conversion for gas. + default_price_unit = None + + elif self._adapter.source_type == "water": + valid_units = VALID_VOLUME_UNITS_WATER + if self.hass.config.units is METRIC_SYSTEM: + default_price_unit = UnitOfVolume.CUBIC_METERS + else: + default_price_unit = UnitOfVolume.GALLONS + energy_state = self.hass.states.get( cast(str, self._config[self._adapter.stat_energy_key]) ) @@ -279,41 +322,27 @@ class EnergyCostSensor(SensorEntity): except ValueError: return - if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith( - f"/{ENERGY_WATT_HOUR}" - ): - energy_price *= 1000.0 + energy_price_unit: str | None = energy_price_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT, "" + ).partition("/")[2] - if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith( - f"/{ENERGY_MEGA_WATT_HOUR}" - ): - energy_price /= 1000.0 + # For backwards compatibility we don't validate the unit of the price + # If it is not valid, we assume it's our default price unit. + if energy_price_unit not in valid_units: + energy_price_unit = default_price_unit else: - energy_price_state = None energy_price = cast(float, self._config["number_energy_price"]) + energy_price_unit = default_price_unit if self._last_energy_sensor_state is None: # Initialize as it's the first time all required entities are in place. self._reset(energy_state) return - energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - - if self._adapter.source_type == "grid": - if energy_unit not in VALID_ENERGY_UNITS: - energy_unit = None - - elif self._adapter.source_type == "gas": - if energy_unit not in VALID_ENERGY_UNITS_GAS: - energy_unit = None - - if energy_unit == ENERGY_WATT_HOUR: - energy_price /= 1000 - elif energy_unit == ENERGY_MEGA_WATT_HOUR: - energy_price *= 1000 + energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if energy_unit is None: + if energy_unit is None or energy_unit not in valid_units: if not self._wrong_unit_reported: self._wrong_unit_reported = True _LOGGER.warning( @@ -343,10 +372,30 @@ class EnergyCostSensor(SensorEntity): energy_state_copy = copy.copy(energy_state) energy_state_copy.state = "0.0" self._reset(energy_state_copy) + # Update with newly incurred cost old_energy_value = float(self._last_energy_sensor_state.state) cur_value = cast(float, self._attr_native_value) - self._attr_native_value = cur_value + (energy - old_energy_value) * energy_price + + if energy_price_unit is None: + converted_energy_price = energy_price + else: + if self._adapter.source_type == "grid": + converter: Callable[ + [float, str, str], float + ] = unit_conversion.EnergyConverter.convert + elif self._adapter.source_type in ("gas", "water"): + converter = unit_conversion.VolumeConverter.convert + + converted_energy_price = converter( + energy_price, + energy_unit, + energy_price_unit, + ) + + self._attr_native_value = ( + cur_value + (energy - old_energy_value) * converted_energy_price + ) self._last_energy_sensor_state = energy_state diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index b704330fa285e2ff50a7f4e2b7c9772346a1c63a..cf4ff3ef63ee31a81fdff96fec48c4e2691b8eb0 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -9,13 +9,13 @@ from typing import Any 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, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, + VOLUME_GALLONS, + VOLUME_LITERS, + UnitOfEnergy, ) from homeassistant.core import HomeAssistant, callback, valid_entity_id @@ -25,9 +25,10 @@ from .const import DOMAIN ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,) ENERGY_USAGE_UNITS = { sensor.SensorDeviceClass.ENERGY: ( - ENERGY_KILO_WATT_HOUR, - ENERGY_MEGA_WATT_HOUR, - ENERGY_WATT_HOUR, + UnitOfEnergy.KILO_WATT_HOUR, + UnitOfEnergy.MEGA_WATT_HOUR, + UnitOfEnergy.WATT_HOUR, + UnitOfEnergy.GIGA_JOULE, ) } ENERGY_PRICE_UNITS = tuple( @@ -41,9 +42,10 @@ GAS_USAGE_DEVICE_CLASSES = ( ) GAS_USAGE_UNITS = { sensor.SensorDeviceClass.ENERGY: ( - ENERGY_WATT_HOUR, - ENERGY_KILO_WATT_HOUR, - ENERGY_MEGA_WATT_HOUR, + UnitOfEnergy.WATT_HOUR, + UnitOfEnergy.KILO_WATT_HOUR, + UnitOfEnergy.MEGA_WATT_HOUR, + UnitOfEnergy.GIGA_JOULE, ), sensor.SensorDeviceClass.GAS: (VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET), } @@ -52,6 +54,20 @@ GAS_PRICE_UNITS = tuple( ) GAS_UNIT_ERROR = "entity_unexpected_unit_gas" GAS_PRICE_UNIT_ERROR = "entity_unexpected_unit_gas_price" +WATER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.WATER,) +WATER_USAGE_UNITS = { + sensor.SensorDeviceClass.WATER: ( + VOLUME_CUBIC_METERS, + VOLUME_CUBIC_FEET, + VOLUME_GALLONS, + VOLUME_LITERS, + ), +} +WATER_PRICE_UNITS = tuple( + f"/{unit}" for units in WATER_USAGE_UNITS.values() for unit in units +) +WATER_UNIT_ERROR = "entity_unexpected_unit_water" +WATER_PRICE_UNIT_ERROR = "entity_unexpected_unit_water_price" @dataclasses.dataclass @@ -437,6 +453,57 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: ) ) + elif source["type"] == "water": + wanted_statistics_metadata.add(source["stat_energy_from"]) + validate_calls.append( + functools.partial( + _async_validate_usage_stat, + hass, + statistics_metadata, + source["stat_energy_from"], + WATER_USAGE_DEVICE_CLASSES, + WATER_USAGE_UNITS, + WATER_UNIT_ERROR, + source_result, + ) + ) + + if (stat_cost := source.get("stat_cost")) is not None: + wanted_statistics_metadata.add(stat_cost) + validate_calls.append( + functools.partial( + _async_validate_cost_stat, + hass, + statistics_metadata, + stat_cost, + source_result, + ) + ) + elif source.get("entity_energy_price") is not None: + validate_calls.append( + functools.partial( + _async_validate_price_entity, + hass, + source["entity_energy_price"], + source_result, + WATER_PRICE_UNITS, + WATER_PRICE_UNIT_ERROR, + ) + ) + + if ( + source.get("entity_energy_price") is not None + or source.get("number_energy_price") is not None + ): + validate_calls.append( + functools.partial( + _async_validate_auto_generated_cost_entity, + hass, + source["stat_energy_from"], + source_result, + ) + ) + elif source["type"] == "solar": wanted_statistics_metadata.add(source["stat_energy_from"]) validate_calls.append( diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 7ba83cf15c905350b542f87678f194c24f62a83b..c2b693c0809a46f5a9eb95d1165c7a975bf34b51 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -13,7 +13,7 @@ from typing import Any, cast import voluptuous as vol from homeassistant.components import recorder, websocket_api -from homeassistant.const import ENERGY_KILO_WATT_HOUR +from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, @@ -82,7 +82,9 @@ def _ws_with_manager( @websocket_api.async_response @functools.wraps(func) async def with_manager( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], ) -> None: manager = await async_get_manager(hass) @@ -146,7 +148,7 @@ async def ws_save_prefs( async def ws_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Handle get info command.""" forecast_platforms = await async_get_energy_platforms(hass) @@ -168,7 +170,7 @@ async def ws_info( async def ws_validate( hass: HomeAssistant, connection: websocket_api.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Handle validate command.""" connection.send_result(msg["id"], (await async_validate(hass)).as_dict()) @@ -183,7 +185,7 @@ async def ws_validate( async def ws_solar_forecast( hass: HomeAssistant, connection: websocket_api.ActiveConnection, - msg: dict, + msg: dict[str, Any], manager: EnergyManager, ) -> None: """Handle solar forecast command.""" @@ -239,7 +241,9 @@ async def ws_solar_forecast( ) @websocket_api.async_response async def ws_get_fossil_energy_consumption( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], ) -> None: """Calculate amount of fossil based energy.""" start_time_str = msg["start_time"] @@ -269,7 +273,7 @@ async def ws_get_fossil_energy_consumption( statistic_ids, "hour", True, - {"energy": ENERGY_KILO_WATT_HOUR}, + {"energy": UnitOfEnergy.KILO_WATT_HOUR}, ) def _combine_sum_statistics( diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 84237852e802a9495c154b905a5e524abdac077d..ae75dfe25c1b636c9f0a1d42f4c97cae727166bf 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -148,8 +148,7 @@ def setup_platform( elif sensor_type == SENSOR_TYPE_WINDOWHANDLE: entities = [EnOceanWindowHandle(dev_id, dev_name, SENSOR_DESC_WINDOWHANDLE)] - if entities: - add_entities(entities) + add_entities(entities) class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index c79c3af604bf5b8b57082ed134c30856a0ee904f..7c4931685269b40dc10f25e2abb73f032460203a 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -34,7 +34,7 @@ SENSORS = ( key="seven_days_production", name="Last Seven Days Energy Production", native_unit_of_measurement=ENERGY_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( @@ -54,14 +54,14 @@ SENSORS = ( key="daily_consumption", name="Today's Energy Consumption", native_unit_of_measurement=ENERGY_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( key="seven_days_consumption", name="Last Seven Days Energy Consumption", native_unit_of_measurement=ENERGY_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..daba57e9488ef7003eb9b1778fe09ded30d0794e --- /dev/null +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -0,0 +1,38 @@ +"""Diagnostics support for Enphase Envoy.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import COORDINATOR, DOMAIN + +CONF_TITLE = "title" + +TO_REDACT = { + CONF_NAME, + CONF_PASSWORD, + # Config entry title and unique ID may contain sensitive data: + CONF_TITLE, + CONF_UNIQUE_ID, + CONF_USERNAME, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + + return async_redact_data( + { + "entry": entry.as_dict(), + "data": coordinator.data, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/enphase_envoy/translations/bg.json b/homeassistant/components/enphase_envoy/translations/bg.json index 1fce5cf396ef3990786b6434bc50b4fc4ba7bb26..7d7949420936889ec15dc645f38630fe072f8dbe 100644 --- a/homeassistant/components/enphase_envoy/translations/bg.json +++ b/homeassistant/components/enphase_envoy/translations/bg.json @@ -1,15 +1,21 @@ { "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", "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" }, + "flow_title": "{serial} ({host})", "step": { "user": { "data": { - "host": "\u0425\u043e\u0441\u0442" + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" } } } diff --git a/homeassistant/components/enphase_envoy/translations/nb.json b/homeassistant/components/enphase_envoy/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/enphase_envoy/translations/nb.json +++ b/homeassistant/components/enphase_envoy/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/enphase_envoy/translations/no.json b/homeassistant/components/enphase_envoy/translations/no.json index 091d76d55ec57544665d4c970fd5c3556d19ff84..5fffefe035fbfcf5f64294f924d1a8221f32fad4 100644 --- a/homeassistant/components/enphase_envoy/translations/no.json +++ b/homeassistant/components/enphase_envoy/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index e980ef0e3960a5d34a069786798289fb4df67a52..fd470ff7c9f6144419e26c7cefc8aaab0c1af9ea 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, @@ -25,8 +24,6 @@ import homeassistant.util.dt as dt_util API_CLIENT_NAME = "homeassistant-homeassistant" -ATTRIBUTION = "Data provided by entur.org under NLOD" - CONF_STOP_IDS = "stop_ids" CONF_EXPAND_PLATFORMS = "expand_platforms" CONF_WHITELIST_LINES = "line_whitelist" @@ -160,6 +157,8 @@ class EnturProxy: class EnturPublicTransportSensor(SensorEntity): """Implementation of a Entur public transport sensor.""" + _attr_attribution = "Data provided by entur.org under NLOD" + def __init__( self, api: EnturProxy, name: str, stop: str, show_on_map: bool ) -> None: @@ -185,7 +184,6 @@ class EnturPublicTransportSensor(SensorEntity): @property def extra_state_attributes(self) -> dict: """Return the state attributes.""" - self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION self._attributes[ATTR_STOP_ID] = self._stop return self._attributes diff --git a/homeassistant/components/environment_canada/translations/nb.json b/homeassistant/components/environment_canada/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/environment_canada/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/epson/translations/bg.json b/homeassistant/components/epson/translations/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..a051d6ca487096e01905f0c9eb4737ae6c8a428a --- /dev/null +++ b/homeassistant/components/epson/translations/bg.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 8846007374e56c392a50f549c9a34a6128cc3b81..23b6a6550e4889d99e89fa595e7ab8f1e7da864f 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -249,6 +249,8 @@ async def async_setup_entry( # noqa: C901 async def on_disconnect() -> None: """Run disconnect callbacks on API disconnect.""" + name = entry_data.device_info.name if entry_data.device_info else host + _LOGGER.debug("%s: %s disconnected, running disconnected callbacks", name, host) for disconnect_cb in entry_data.disconnect_callbacks: disconnect_cb() entry_data.disconnect_callbacks = [] @@ -307,6 +309,8 @@ def _async_setup_device_registry( configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" manufacturer = "espressif" + if device_info.manufacturer: + manufacturer = device_info.manufacturer model = device_info.model hw_version = None if device_info.project_name: @@ -468,6 +472,7 @@ async def _cleanup_instance( """Cleanup the esphome client if it exists.""" domain_data = DomainData.get(hass) data = domain_data.pop_entry_data(entry) + data.available = False for disconnect_cb in data.disconnect_callbacks: disconnect_cb() data.disconnect_callbacks = [] diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index b4d5fdbd04df145dfa316c3d4f01b5dde04c4710..b5be536247485824008d52a07124633df7823c99 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -30,13 +30,15 @@ def _async_can_connect_factory( @hass_callback def _async_can_connect() -> bool: """Check if a given source can make another connection.""" + can_connect = bool(entry_data.available and entry_data.ble_connections_free) _LOGGER.debug( - "Checking if %s can connect, available=%s, ble_connections_free=%s", + "%s: Checking can connect, available=%s, ble_connections_free=%s result=%s", source, entry_data.available, entry_data.ble_connections_free, + can_connect, ) - return bool(entry_data.available and entry_data.ble_connections_free) + return can_connect return _async_can_connect @@ -55,7 +57,7 @@ async def async_connect_scanner( version = entry_data.device_info.bluetooth_proxy_version connectable = version >= 2 _LOGGER.debug( - "Connecting scanner for %s, version=%s, connectable=%s", + "%s: Connecting scanner version=%s, connectable=%s", source, version, connectable, diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index eda75436502c6d1129b26bbc6e06402211399010..72531a2503a88c7115c83277ebbd82c94a4900e4 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -7,6 +7,11 @@ import logging from typing import Any, TypeVar, cast import uuid +from aioesphomeapi import ( + ESP_CONNECTION_ERROR_DESCRIPTION, + ESPHOME_GATT_ERRORS, + BLEConnectionError, +) from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError import async_timeout from bleak.backends.characteristic import BleakGATTCharacteristic @@ -47,9 +52,23 @@ def verify_connected(func: _WrapFuncType) -> _WrapFuncType: async def _async_wrap_bluetooth_connected_operation( self: "ESPHomeClient", *args: Any, **kwargs: Any ) -> Any: - if not self._is_connected: # pylint: disable=protected-access + disconnected_event = ( + self._disconnected_event # pylint: disable=protected-access + ) + if not disconnected_event: raise BleakError("Not connected") - return await func(self, *args, **kwargs) + task = asyncio.create_task(func(self, *args, **kwargs)) + done, _ = await asyncio.wait( + (task, disconnected_event.wait()), + return_when=asyncio.FIRST_COMPLETED, + ) + if disconnected_event.is_set(): + task.cancel() + raise BleakError( + f"{self._source}: {self._ble_device.name} - {self._ble_device.address}: " # pylint: disable=protected-access + "Disconnected during operation" + ) + return next(iter(done)).result() return cast(_WrapFuncType, _async_wrap_bluetooth_connected_operation) @@ -91,6 +110,7 @@ class ESPHomeClient(BaseBleakClient): self._mtu: int | None = None self._cancel_connection_state: CALLBACK_TYPE | None = None self._notify_cancels: dict[int, Callable[[], Coroutine[Any, Any, None]]] = {} + self._disconnected_event: asyncio.Event | None = None def __str__(self) -> str: """Return the string representation of the client.""" @@ -104,22 +124,40 @@ class ESPHomeClient(BaseBleakClient): self._cancel_connection_state() except (AssertionError, ValueError) as ex: _LOGGER.debug( - "Failed to unsubscribe from connection state (likely connection dropped): %s", + "%s: %s - %s: Failed to unsubscribe from connection state (likely connection dropped): %s", + self._source, + self._ble_device.name, + self._ble_device.address, ex, ) self._cancel_connection_state = None def _async_ble_device_disconnected(self) -> None: """Handle the BLE device disconnecting from the ESP.""" - _LOGGER.debug("%s: BLE device disconnected", self._source) - self._is_connected = False + was_connected = self._is_connected self.services = BleakGATTServiceCollection() # type: ignore[no-untyped-call] - self._async_call_bleak_disconnected_callback() + self._is_connected = False + if self._disconnected_event: + self._disconnected_event.set() + self._disconnected_event = None + if was_connected: + _LOGGER.debug( + "%s: %s - %s: BLE device disconnected", + self._source, + self._ble_device.name, + self._ble_device.address, + ) + self._async_call_bleak_disconnected_callback() self._unsubscribe_connection_state() def _async_esp_disconnected(self) -> None: """Handle the esp32 client disconnecting from hass.""" - _LOGGER.debug("%s: ESP device disconnected", self._source) + _LOGGER.debug( + "%s: %s - %s: ESP device disconnected", + self._source, + self._ble_device.name, + self._ble_device.address, + ) self.entry_data.disconnect_callbacks.remove(self._async_esp_disconnected) self._async_ble_device_disconnected() @@ -149,7 +187,10 @@ class ESPHomeClient(BaseBleakClient): ) -> None: """Handle a connect or disconnect.""" _LOGGER.debug( - "Connection state changed: connected=%s mtu=%s error=%s", + "%s: %s - %s: Connection state changed to connected=%s mtu=%s error=%s", + self._source, + self._ble_device.name, + self._ble_device.address, connected, mtu, error, @@ -164,8 +205,19 @@ class ESPHomeClient(BaseBleakClient): return if error: + try: + ble_connection_error = BLEConnectionError(error) + ble_connection_error_name = ble_connection_error.name + human_error = ESP_CONNECTION_ERROR_DESCRIPTION[ble_connection_error] + except (KeyError, ValueError): + ble_connection_error_name = str(error) + human_error = ESPHOME_GATT_ERRORS.get( + error, f"Unknown error code {error}" + ) connected_future.set_exception( - BleakError(f"Error while connecting: {error}") + BleakError( + f"Error {ble_connection_error_name} while connecting: {human_error}" + ) ) return @@ -173,6 +225,12 @@ class ESPHomeClient(BaseBleakClient): connected_future.set_exception(BleakError("Disconnected")) return + _LOGGER.debug( + "%s: %s - %s: connected, registering for disconnected callbacks", + self._source, + self._ble_device.name, + self._ble_device.address, + ) self.entry_data.disconnect_callbacks.append(self._async_esp_disconnected) connected_future.set_result(connected) @@ -184,6 +242,7 @@ class ESPHomeClient(BaseBleakClient): ) await connected_future await self.get_services(dangerous_use_bleak_cache=dangerous_use_bleak_cache) + self._disconnected_event = asyncio.Event() return True @api_error_as_bleak_error @@ -199,7 +258,10 @@ class ESPHomeClient(BaseBleakClient): if self.entry_data.ble_connections_free: return _LOGGER.debug( - "%s: Out of connection slots, waiting for a free one", self._source + "%s: %s - %s: Out of connection slots, waiting for a free one", + self._source, + self._ble_device.name, + self._ble_device.address, ) async with async_timeout.timeout(timeout): await self.entry_data.wait_for_ble_connections_free() @@ -236,25 +298,34 @@ class ESPHomeClient(BaseBleakClient): A :py:class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree. """ address_as_int = self._address_as_int - domain_data = self.domain_data + entry_data = self.entry_data if dangerous_use_bleak_cache and ( - cached_services := domain_data.get_gatt_services_cache(address_as_int) + cached_services := entry_data.get_gatt_services_cache(address_as_int) ): _LOGGER.debug( - "Cached services hit for %s - %s", + "%s: %s - %s: Cached services hit", + self._source, self._ble_device.name, self._ble_device.address, ) self.services = cached_services return self.services _LOGGER.debug( - "Cached services miss for %s - %s", + "%s: %s - %s: Cached services miss", + self._source, self._ble_device.name, self._ble_device.address, ) esphome_services = await self._client.bluetooth_gatt_get_services( address_as_int ) + _LOGGER.debug( + "%s: %s - %s: Got services: %s", + self._source, + self._ble_device.name, + self._ble_device.address, + esphome_services, + ) max_write_without_response = self.mtu_size - GATT_HEADER_SIZE services = BleakGATTServiceCollection() # type: ignore[no-untyped-call] for service in esphome_services.services: @@ -278,11 +349,12 @@ class ESPHomeClient(BaseBleakClient): ) self.services = services _LOGGER.debug( - "Cached services saved for %s - %s", + "%s: %s - %s: Cached services saved", + self._source, self._ble_device.name, self._ble_device.address, ) - domain_data.set_gatt_services_cache(address_as_int, services) + entry_data.set_gatt_services_cache(address_as_int, services) return services def _resolve_characteristic( diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index 36138192f8fdcb51654afb6aefd58815d25f3e9f..4fbaf7cabb6e0ed5009bdff2ffbb13637130df03 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -6,26 +6,33 @@ import datetime from datetime import timedelta import re import time +from typing import Final from aioesphomeapi import BluetoothLEAdvertisement from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from homeassistant.components.bluetooth import BaseHaScanner, HaBluetoothConnector -from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + BaseHaScanner, + BluetoothServiceInfoBleak, + HaBluetoothConnector, +) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_track_time_interval - -# We have to set this quite high as we don't know -# when devices fall out of the esphome device's stack -# like we do with BlueZ so its safer to assume its available -# since if it does go out of range and it is in range -# of another device the timeout is much shorter and it will -# switch over to using that adapter anyways. -ADV_STALE_TIME = 60 * 15 # seconds +from homeassistant.util.dt import monotonic_time_coarse TWO_CHAR = re.compile("..") +# The maximum time between advertisements for a device to be considered +# stale when the advertisement tracker can determine the interval for +# connectable devices. +# +# BlueZ uses 180 seconds by default but we give it a bit more time +# to account for the esp32's bluetooth stack being a bit slower +# than BlueZ's. +CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195 + class ESPHomeScanner(BaseHaScanner): """Scanner for esphome.""" @@ -39,22 +46,27 @@ class ESPHomeScanner(BaseHaScanner): connectable: bool, ) -> None: """Initialize the scanner.""" - self._hass = hass + super().__init__(hass, scanner_id) self._new_info_callback = new_info_callback - self._discovered_devices: dict[str, BLEDevice] = {} + self._discovered_device_advertisement_datas: dict[ + str, tuple[BLEDevice, AdvertisementData] + ] = {} self._discovered_device_timestamps: dict[str, float] = {} - self._source = scanner_id self._connector = connector self._connectable = connectable self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id} + self._fallback_seconds = FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS if connectable: self._details["connector"] = connector + self._fallback_seconds = ( + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + ) @callback def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" return async_track_time_interval( - self._hass, self._async_expire_devices, timedelta(seconds=30) + self.hass, self._async_expire_devices, timedelta(seconds=30) ) def _async_expire_devices(self, _datetime: datetime.datetime) -> None: @@ -63,57 +75,70 @@ class ESPHomeScanner(BaseHaScanner): expired = [ address for address, timestamp in self._discovered_device_timestamps.items() - if now - timestamp > ADV_STALE_TIME + if now - timestamp > self._fallback_seconds ] for address in expired: - del self._discovered_devices[address] + del self._discovered_device_advertisement_datas[address] del self._discovered_device_timestamps[address] @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" - return list(self._discovered_devices.values()) + return [ + device_advertisement_data[0] + for device_advertisement_data in self._discovered_device_advertisement_datas.values() + ] - async def async_get_device_by_address(self, address: str) -> BLEDevice | None: - """Get a device by address.""" - return self._discovered_devices.get(address) + @property + def discovered_devices_and_advertisement_data( + self, + ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: + """Return a list of discovered devices and advertisement data.""" + return self._discovered_device_advertisement_datas @callback def async_on_advertisement(self, adv: BluetoothLEAdvertisement) -> None: """Call the registered callback.""" - now = time.monotonic() + now = monotonic_time_coarse() address = ":".join(TWO_CHAR.findall("%012X" % adv.address)) # must be upper name = adv.name - if prev_discovery := self._discovered_devices.get(address): + if prev_discovery := self._discovered_device_advertisement_datas.get(address): # If the last discovery had the full local name # and this one doesn't, keep the old one as we # always want the full local name over the short one - if len(prev_discovery.name) > len(adv.name): - name = prev_discovery.name + prev_device = prev_discovery[0] + if len(prev_device.name) > len(adv.name): + name = prev_device.name - advertisement_data = AdvertisementData( # type: ignore[no-untyped-call] + advertisement_data = AdvertisementData( local_name=None if name == "" else name, manufacturer_data=adv.manufacturer_data, service_data=adv.service_data, service_uuids=adv.service_uuids, + rssi=adv.rssi, + tx_power=-127, + platform_data=(), ) device = BLEDevice( # type: ignore[no-untyped-call] address=address, name=name, details=self._details, - rssi=adv.rssi, + rssi=adv.rssi, # deprecated, will be removed in newer bleak + ) + self._discovered_device_advertisement_datas[address] = ( + device, + advertisement_data, ) - self._discovered_devices[address] = device self._discovered_device_timestamps[address] = now self._new_info_callback( BluetoothServiceInfoBleak( name=advertisement_data.local_name or device.name or device.address, address=device.address, - rssi=device.rssi, + rssi=adv.rssi, manufacturer_data=advertisement_data.manufacturer_data, service_data=advertisement_data.service_data, service_uuids=advertisement_data.service_uuids, - source=self._source, + source=self.source, device=device, advertisement=advertisement_data, connectable=self._connectable, diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index acaa76185e76a81a72381a397cee8419fb28319b..01f0a4d6b1369b6f6908d943c821bb3805e59e57 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -1,13 +1,9 @@ """Support for esphome domain data.""" from __future__ import annotations -from collections.abc import MutableMapping from dataclasses import dataclass, field from typing import TypeVar, cast -from bleak.backends.service import BleakGATTServiceCollection -from lru import LRU # pylint: disable=no-name-in-module - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder @@ -17,7 +13,6 @@ from .entry_data import RuntimeEntryData STORAGE_VERSION = 1 DOMAIN = "esphome" -MAX_CACHED_SERVICES = 128 _DomainDataSelfT = TypeVar("_DomainDataSelfT", bound="DomainData") @@ -29,21 +24,6 @@ class DomainData: _entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict) _stores: dict[str, Store] = field(default_factory=dict) _entry_by_unique_id: dict[str, ConfigEntry] = field(default_factory=dict) - _gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field( - default_factory=lambda: LRU(MAX_CACHED_SERVICES) # type: ignore[no-any-return] - ) - - def get_gatt_services_cache( - self, address: int - ) -> BleakGATTServiceCollection | None: - """Get the BleakGATTServiceCollection for the given address.""" - return self._gatt_services_cache.get(address) - - def set_gatt_services_cache( - self, address: int, services: BleakGATTServiceCollection - ) -> None: - """Set the BleakGATTServiceCollection for the given address.""" - self._gatt_services_cache[address] = services def get_by_unique_id(self, unique_id: str) -> ConfigEntry: """Get the config entry by its unique ID.""" diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index ac2a148d89913ded4cadaf4ee2b9ad877d1d759f..faa9074b880e88419f9d9774e67fc6b3a7ac276f 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, MutableMapping from dataclasses import dataclass, field import logging from typing import Any, cast @@ -30,6 +30,8 @@ from aioesphomeapi import ( UserService, ) from aioesphomeapi.model import ButtonInfo +from bleak.backends.service import BleakGATTServiceCollection +from lru import LRU # pylint: disable=no-name-in-module from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -57,6 +59,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], str] = { SwitchInfo: Platform.SWITCH, TextSensorInfo: Platform.SENSOR, } +MAX_CACHED_SERVICES = 128 @dataclass @@ -92,12 +95,37 @@ class RuntimeEntryData: _ble_connection_free_futures: list[asyncio.Future[int]] = field( default_factory=list ) + _gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field( + default_factory=lambda: LRU(MAX_CACHED_SERVICES) # type: ignore[no-any-return] + ) + + @property + def name(self) -> str: + """Return the name of the device.""" + return self.device_info.name if self.device_info else self.entry_id + + def get_gatt_services_cache( + self, address: int + ) -> BleakGATTServiceCollection | None: + """Get the BleakGATTServiceCollection for the given address.""" + return self._gatt_services_cache.get(address) + + def set_gatt_services_cache( + self, address: int, services: BleakGATTServiceCollection + ) -> None: + """Set the BleakGATTServiceCollection for the given address.""" + self._gatt_services_cache[address] = services @callback def async_update_ble_connection_limits(self, free: int, limit: int) -> None: """Update the BLE connection limits.""" - name = self.device_info.name if self.device_info else self.entry_id - _LOGGER.debug("%s: BLE connection limits: %s/%s", name, free, limit) + _LOGGER.debug( + "%s: BLE connection limits: used=%s free=%s limit=%s", + self.name, + limit - free, + free, + limit, + ) self.ble_connections_free = free self.ble_connections_limit = limit if free: @@ -168,7 +196,8 @@ class RuntimeEntryData: subscription_key = (type(state), state.key) self.state[type(state)][state.key] = state _LOGGER.debug( - "Dispatching update with key %s: %s", + "%s: dispatching update with key %s: %s", + self.name, subscription_key, state, ) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 3ffceaae97173da654a9261654e1114e3f41d359..64cd6b4029c6aaabd38abc60bc2926c14cc50882 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,11 +3,12 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==11.1.1"], + "requirements": ["aioesphomeapi==11.4.2"], "zeroconf": ["_esphomelib._tcp.local."], "dhcp": [{ "registered_devices": true }], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["bluetooth", "zeroconf", "tag"], "iot_class": "local_push", + "integration_type": "device", "loggers": ["aioesphomeapi", "noiseprotocol"] } diff --git a/homeassistant/components/esphome/translations/hu.json b/homeassistant/components/esphome/translations/hu.json index 1f98953678c1b8788e1f4c6770bd89a8b9d8d713..41550d02a43736ef3225fe3f0854b26cb80cafb8 100644 --- a/homeassistant/components/esphome/translations/hu.json +++ b/homeassistant/components/esphome/translations/hu.json @@ -20,8 +20,8 @@ "description": "K\u00e9rem, adja meg a konfigur\u00e1ci\u00f3ban l\u00e9v\u0151, {name} jelszav\u00e1t." }, "discovery_confirm": { - "description": "Szeretn\u00e9 hozz\u00e1adni `{name}` ESPHome csom\u00f3pontot Home Assistanthoz?", - "title": "ESPHome csom\u00f3pont felfedezve" + "description": "Szeretn\u00e9 hozz\u00e1adni a `{name}` ESPHome v\u00e9gpontot Home Assistanthoz?", + "title": "ESPHome v\u00e9gpont felfedezve" }, "encryption_key": { "data": { @@ -40,7 +40,7 @@ "host": "C\u00edm", "port": "Port" }, - "description": "K\u00e9rem, adja meg az [ESPHome]({esphome_url}) csom\u00f3pontj\u00e1nak kapcsol\u00f3d\u00e1si be\u00e1ll\u00edt\u00e1sait." + "description": "K\u00e9rem, adja meg az [ESPHome]({esphome_url}) v\u00e9gpontj\u00e1nak kapcsol\u00f3d\u00e1si be\u00e1ll\u00edt\u00e1sait." } } } diff --git a/homeassistant/components/esphome/translations/no.json b/homeassistant/components/esphome/translations/no.json index 1fabb774aa21f15aede87dde49043c30786383e4..e17b9dad8150a21eceae81b2a2c725dd31a3afb9 100644 --- a/homeassistant/components/esphome/translations/no.json +++ b/homeassistant/components/esphome/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "connection_error": "Kan ikke koble til ESP. Kontroller at YAML filen din inneholder en \"api:\" linje.", diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py index 9f0c6f7eca912365cf859501873ef54168a45bc8..d98ddcba23c61d79435f6dc12ec8e2d9fcfa3085 100644 --- a/homeassistant/components/etherscan/sensor.py +++ b/homeassistant/components/etherscan/sensor.py @@ -7,14 +7,12 @@ from pyetherscan import get_balance import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_ADDRESS, CONF_NAME, CONF_TOKEN +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TOKEN 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 -ATTRIBUTION = "Data provided by etherscan.io" - CONF_TOKEN_ADDRESS = "token_address" SCAN_INTERVAL = timedelta(minutes=5) @@ -54,6 +52,8 @@ def setup_platform( class EtherscanSensor(SensorEntity): """Representation of an Etherscan.io sensor.""" + _attr_attribution = "Data provided by etherscan.io" + def __init__(self, name, address, token, token_address): """Initialize the sensor.""" self._name = name @@ -78,11 +78,6 @@ class EtherscanSensor(SensorEntity): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - def update(self) -> None: """Get the latest state of the sensor.""" diff --git a/homeassistant/components/evil_genius_labs/translations/nb.json b/homeassistant/components/evil_genius_labs/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/bg.json b/homeassistant/components/ezviz/translations/bg.json index 7e54efd88a9425807d32b2fbc1d595deee1cd27d..d380e383fcf5dfc0798ffdf92537bee7ec601a63 100644 --- a/homeassistant/components/ezviz/translations/bg.json +++ b/homeassistant/components/ezviz/translations/bg.json @@ -1,17 +1,36 @@ { "config": { + "abort": { + "already_configured_account": "\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", + "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" + "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", + "invalid_host": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0438\u043c\u0435 \u043d\u0430 \u0445\u043e\u0441\u0442 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441" }, "flow_title": "{serial}", "step": { "confirm": { "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" } }, "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "url": "URL", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + }, "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Ezviz Cloud" + }, + "user_custom_url": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "url": "URL", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } } } } diff --git a/homeassistant/components/ezviz/translations/ca.json b/homeassistant/components/ezviz/translations/ca.json index 7c71de300f683621e20abc1efcdacf61be8a1ec9..126a563a1e5be748e929f48f28ef4bc1bdd7b672 100644 --- a/homeassistant/components/ezviz/translations/ca.json +++ b/homeassistant/components/ezviz/translations/ca.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_account": "El compte ja est\u00e0 configurat", - "ezviz_cloud_account_missing": "Falta el compte d'Ezviz cloud. Torna'l a configurar", + "ezviz_cloud_account_missing": "Falta el compte d'EZVIZ cloud. Torna'l a configurar", "unknown": "Error inesperat" }, "error": { @@ -17,8 +17,8 @@ "password": "Contrasenya", "username": "Nom d'usuari" }, - "description": "Introdueix les credencials RTSP per a la c\u00e0mera Ezviz {serial} amb IP {ip_address}", - "title": "S'ha descobert c\u00e0mera Ezviz" + "description": "Introdueix les credencials RTSP de la c\u00e0mera EZVIZ {serial} amb IP {ip_address}", + "title": "S'ha descobert una c\u00e0mera EZVIZ" }, "user": { "data": { @@ -26,7 +26,7 @@ "url": "URL", "username": "Nom d'usuari" }, - "title": "Connexi\u00f3 amb Ezviz Cloud" + "title": "Connexi\u00f3 amb EZVIZ Cloud" }, "user_custom_url": { "data": { @@ -35,7 +35,7 @@ "username": "Nom d'usuari" }, "description": "Especifica manualment l'URL de teva regi\u00f3", - "title": "Connexi\u00f3 amb URL de Ezviz personalitzat" + "title": "Connexi\u00f3 a URL d'EZVIZ personalitzat" } } }, diff --git a/homeassistant/components/ezviz/translations/de.json b/homeassistant/components/ezviz/translations/de.json index 92faeff2b81cbcbb170af42cd64b6912c631421e..0cd59cb50b91395e0051fbbb1f33a4eb1eb4fd2a 100644 --- a/homeassistant/components/ezviz/translations/de.json +++ b/homeassistant/components/ezviz/translations/de.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_account": "Konto wurde bereits konfiguriert", - "ezviz_cloud_account_missing": "Ezviz-Cloud-Konto fehlt. Bitte konfiguriere das Ezviz-Cloud-Konto neu", + "ezviz_cloud_account_missing": "EZVIZ-Cloud-Konto fehlt. Bitte konfiguriere das EZVIZ-Cloud-Konto neu", "unknown": "Unerwarteter Fehler" }, "error": { @@ -17,8 +17,8 @@ "password": "Passwort", "username": "Benutzername" }, - "description": "RTSP-Anmeldeinformationen f\u00fcr Ezviz-Kamera {serial} mit IP {ip_address} eingeben", - "title": "Entdeckte Ezviz-Kamera" + "description": "RTSP-Anmeldeinformationen f\u00fcr EZVIZ-Kamera {serial} mit IP {ip_address} eingeben", + "title": "Entdeckte EZVIZ-Kamera" }, "user": { "data": { @@ -26,7 +26,7 @@ "url": "URL", "username": "Benutzername" }, - "title": "Verbinden mit Ezviz Cloud" + "title": "Verbindung zur EZVIZ Cloud" }, "user_custom_url": { "data": { @@ -35,7 +35,7 @@ "username": "Benutzername" }, "description": "URL Region manuell festlegen", - "title": "Verbinden mit benutzerdefinierter Ezviz-URL" + "title": "Verbinden mit benutzerdefinierter EZVIZ-URL" } } }, diff --git a/homeassistant/components/ezviz/translations/es.json b/homeassistant/components/ezviz/translations/es.json index a69cb8d5d247d62f494f295a97b612d311a1a1da..1c7305c53f6bf9defca3699a379bd4f32d116e9f 100644 --- a/homeassistant/components/ezviz/translations/es.json +++ b/homeassistant/components/ezviz/translations/es.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_account": "La cuenta ya est\u00e1 configurada", - "ezviz_cloud_account_missing": "Falta la cuenta de Ezviz Cloud. Por favor, vuelve a configurar la cuenta de Ezviz Cloud", + "ezviz_cloud_account_missing": "Falta la cuenta de EZVIZ Cloud. Por favor, vuelve a configurar la cuenta de EZVIZ Cloud", "unknown": "Error inesperado" }, "error": { @@ -17,8 +17,8 @@ "password": "Contrase\u00f1a", "username": "Nombre de usuario" }, - "description": "Introduce las credenciales RTSP para la c\u00e1mara Ezviz {serial} con IP {ip_address}", - "title": "Descubierta c\u00e1mara Ezviz" + "description": "Introduce las credenciales RTSP para la c\u00e1mara EZVIZ {serial} con IP {ip_address}", + "title": "Descubierta c\u00e1mara EZVIZ" }, "user": { "data": { @@ -26,7 +26,7 @@ "url": "URL", "username": "Nombre de usuario" }, - "title": "Conectar con Ezviz Cloud" + "title": "Conectar con EZVIZ Cloud" }, "user_custom_url": { "data": { @@ -35,7 +35,7 @@ "username": "Nombre de usuario" }, "description": "Especificar manualmente la URL de tu regi\u00f3n", - "title": "Conectar a la URL personalizada de Ezviz" + "title": "Conectar a la URL personalizada de EZVIZ" } } }, diff --git a/homeassistant/components/ezviz/translations/et.json b/homeassistant/components/ezviz/translations/et.json index 55a6e6784c112629ae448cf7918246e394cdcab1..1897d57aa91ae3c3542c6c6a350473c5e092a43e 100644 --- a/homeassistant/components/ezviz/translations/et.json +++ b/homeassistant/components/ezviz/translations/et.json @@ -35,7 +35,7 @@ "username": "Kasutajanimi" }, "description": "M\u00e4\u00e4ra oma piirkonna URL k\u00e4sitsi", - "title": "\u00dchenduse loomine kohandatud Ezvizi URL-iga" + "title": "Loo \u00fchendus kohandatud EZVIZi URL-iga" } } }, diff --git a/homeassistant/components/ezviz/translations/fr.json b/homeassistant/components/ezviz/translations/fr.json index 475eb0bcfc737bd335fcc8d8631e229b5554b538..33ff73f59d4f2c597416cbff69d36a2f41dd2e53 100644 --- a/homeassistant/components/ezviz/translations/fr.json +++ b/homeassistant/components/ezviz/translations/fr.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_account": "Le compte est d\u00e9j\u00e0 configur\u00e9", - "ezviz_cloud_account_missing": "Compte cloud Ezviz manquant. Veuillez reconfigurer le compte cloud Ezviz", + "ezviz_cloud_account_missing": "Compte cloud EZVIZ manquant. Veuillez reconfigurer le compte cloud EZVIZ", "unknown": "Erreur inattendue" }, "error": { @@ -17,8 +17,8 @@ "password": "Mot de passe", "username": "Nom d'utilisateur" }, - "description": "Entrez les informations d'identification RTSP pour la cam\u00e9ra Ezviz {serial} avec IP {ip_address}", - "title": "Cam\u00e9ra Ezviz d\u00e9couverte" + "description": "Saisissez les informations d'identification RTSP pour la cam\u00e9ra EZVIZ {serial} \u00e0 l'adresse\u00a0IP {ip_address}", + "title": "Cam\u00e9ra EZVIZ d\u00e9couverte" }, "user": { "data": { @@ -26,7 +26,7 @@ "url": "URL", "username": "Nom d'utilisateur" }, - "title": "Connectez-vous \u00e0 Ezviz Cloud" + "title": "Connectez-vous \u00e0 EZVIZ Cloud" }, "user_custom_url": { "data": { @@ -35,7 +35,7 @@ "username": "Nom d'utilisateur" }, "description": "Sp\u00e9cifiez manuellement l'URL de votre r\u00e9gion", - "title": "Connectez-vous \u00e0 l'URL Ezviz personnalis\u00e9e" + "title": "Connectez-vous \u00e0 l'URL EZVIZ personnalis\u00e9e" } } }, diff --git a/homeassistant/components/ezviz/translations/hu.json b/homeassistant/components/ezviz/translations/hu.json index 49cd4b43d0897f554546d4b58c4add38faa2a0a3..7c9b5218f918cdfbb46179af09093062e995a037 100644 --- a/homeassistant/components/ezviz/translations/hu.json +++ b/homeassistant/components/ezviz/translations/hu.json @@ -17,7 +17,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "\u00cdrja be az RTSP-hiteles\u00edt\u0151 adatokat az Ezviz {serial} kamer\u00e1hoz IP- {ip_address}", + "description": "\u00cdrja be az RTSP-hiteles\u00edt\u0151 adatokat az Ezviz {serial}, {ip_address} IP-c\u00edm\u0171 kamer\u00e1hoz", "title": "Felfedezett Ezviz kamera" }, "user": { @@ -35,7 +35,7 @@ "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, "description": "Adja meg k\u00e9zzel a r\u00e9gi\u00f3 URL-j\u00e9t", - "title": "Csatlakozzon az Ezviz-hez egy\u00e9ni URL" + "title": "Csatlakozzon egyedi Ezviz URL-hez" } } }, diff --git a/homeassistant/components/ezviz/translations/id.json b/homeassistant/components/ezviz/translations/id.json index e263b00c7dac228d652e7fb19b5b13c460a5157d..1859b1cb0bc8f9af8c82850f13eebf63edc71a1e 100644 --- a/homeassistant/components/ezviz/translations/id.json +++ b/homeassistant/components/ezviz/translations/id.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_account": "Akun sudah dikonfigurasi", - "ezviz_cloud_account_missing": "Akun cloud Ezviz tidak tersedia. Konfigurasi ulang akun cloud Ezviz", + "ezviz_cloud_account_missing": "Akun cloud EZVIZ tidak tersedia. Konfigurasi ulang akun cloud EZVIZ", "unknown": "Kesalahan yang tidak diharapkan" }, "error": { @@ -17,8 +17,8 @@ "password": "Kata Sandi", "username": "Nama Pengguna" }, - "description": "Masukkan kredensial RTSP untuk kamera Ezviz {serial} dengan IP {ip_address}", - "title": "Kamera Ezviz yang ditemukan" + "description": "Masukkan kredensial RTSP untuk kamera EZVIZ {serial} dengan IP {ip_address}", + "title": "Kamera EZVIZ yang ditemukan" }, "user": { "data": { @@ -26,7 +26,7 @@ "url": "URL", "username": "Nama Pengguna" }, - "title": "Hubungkan ke Ezviz Cloud" + "title": "Hubungkan ke EZVIZ Cloud" }, "user_custom_url": { "data": { @@ -35,7 +35,7 @@ "username": "Nama Pengguna" }, "description": "Tentukan URL wilayah Anda secara manual", - "title": "Hubungkan ke URL Ezviz khusus" + "title": "Hubungkan ke URL EZVIZ khusus" } } }, diff --git a/homeassistant/components/ezviz/translations/it.json b/homeassistant/components/ezviz/translations/it.json index febba0cad51c8775bb1fa02b268bd89d0223a53b..9ef6bb4b6c22672e4b5bd5e77cd3d49bd0f6dbc4 100644 --- a/homeassistant/components/ezviz/translations/it.json +++ b/homeassistant/components/ezviz/translations/it.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_account": "L'account \u00e8 gi\u00e0 configurato", - "ezviz_cloud_account_missing": "Ezviz cloud account mancante. Riconfigura l'account Ezviz cloud", + "ezviz_cloud_account_missing": "Account EZVIZ cloud mancante. Si prega di riconfigurare l'account EZVIZ cloud", "unknown": "Errore imprevisto" }, "error": { @@ -17,8 +17,8 @@ "password": "Password", "username": "Nome utente" }, - "description": "Inserisci le credenziali RTSP per la videocamera Ezviz {serial} con IP {ip_address}", - "title": "Rilevata videocamera Ezviz" + "description": "Inserisci le credenziali RTSP per la videocamera EZVIZ {serial} con IP {ip_address}", + "title": "Rilevata videocamera EZVIZ" }, "user": { "data": { @@ -26,7 +26,7 @@ "url": "URL", "username": "Nome utente" }, - "title": "Connettiti a Ezviz Cloud" + "title": "Connettiti a EZVIZ Cloud" }, "user_custom_url": { "data": { @@ -35,7 +35,7 @@ "username": "Nome utente" }, "description": "Specifica manualmente l'URL dell'area geografica", - "title": "Connettiti all'URL personalizzato di Ezviz" + "title": "Connettiti all'URL EZVIZ personalizzato" } } }, diff --git a/homeassistant/components/ezviz/translations/nb.json b/homeassistant/components/ezviz/translations/nb.json index 533218a036aecf04c932531fedcf4f42cc1b15e2..a0814bbe6220bb76e7c6f80f56f62b9433953b55 100644 --- a/homeassistant/components/ezviz/translations/nb.json +++ b/homeassistant/components/ezviz/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "unknown": "Uventet feil" + }, "step": { "confirm": { "data": { diff --git a/homeassistant/components/ezviz/translations/no.json b/homeassistant/components/ezviz/translations/no.json index 306babef86c9b5b6043788400ba678562f518baf..a8351b205f9b953500db85c7dce0fed4c46f3f4f 100644 --- a/homeassistant/components/ezviz/translations/no.json +++ b/homeassistant/components/ezviz/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_account": "Kontoen er allerede konfigurert", - "ezviz_cloud_account_missing": "Ezviz sky-konto mangler. Vennligst konfigurer Ezviz sky-konto p\u00e5 nytt", + "ezviz_cloud_account_missing": "EZVIZ skykonto mangler. Vennligst rekonfigurer EZVIZ skykonto", "unknown": "Uventet feil" }, "error": { @@ -17,8 +17,8 @@ "password": "Passord", "username": "Brukernavn" }, - "description": "Angi RTSP-legitimasjon for Ezviz-kameraet {serial} med IP {ip_address}", - "title": "Oppdaget Ezviz Kamera" + "description": "Skriv inn RTSP-legitimasjon for EZVIZ-kamera {serial} med IP {ip_address}", + "title": "Oppdaget EZVIZ-kamera" }, "user": { "data": { @@ -26,7 +26,7 @@ "url": "URL", "username": "Brukernavn" }, - "title": "Koble til Ezviz Cloud" + "title": "Koble til EZVIZ Cloud" }, "user_custom_url": { "data": { @@ -35,7 +35,7 @@ "username": "Brukernavn" }, "description": "Angi url-adressen for omr\u00e5det manuelt", - "title": "Koble til tilpasset Ezviz URL" + "title": "Koble til egendefinert EZVIZ URL" } } }, diff --git a/homeassistant/components/ezviz/translations/pl.json b/homeassistant/components/ezviz/translations/pl.json index a8413da6188b83c81f54b4ffccd058290dabe653..e59f5c3d86f4134a9b2f836ebfe10c7469ae9b90 100644 --- a/homeassistant/components/ezviz/translations/pl.json +++ b/homeassistant/components/ezviz/translations/pl.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_account": "Konto jest ju\u017c skonfigurowane", - "ezviz_cloud_account_missing": "Brak konta Ezviz. Skonfiguruj ponownie konto Ezviz.", + "ezviz_cloud_account_missing": "Brak konta EZVIZ. Skonfiguruj ponownie konto EZVIZ.", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "error": { @@ -17,8 +17,8 @@ "password": "Has\u0142o", "username": "Nazwa u\u017cytkownika" }, - "description": "Wpisz dane logowania RTSP dla kamery Ezviz {serial} z IP {ip_address}", - "title": "Wykryto kamer\u0119 Ezviz" + "description": "Wpisz dane logowania RTSP dla kamery EZVIZ {serial} z IP {ip_address}", + "title": "Wykryto kamer\u0119 EZVIZ" }, "user": { "data": { @@ -26,7 +26,7 @@ "url": "URL", "username": "Nazwa u\u017cytkownika" }, - "title": "Po\u0142\u0105czenie z chmur\u0105 Ezviz" + "title": "Po\u0142\u0105czenie z chmur\u0105 EZVIZ" }, "user_custom_url": { "data": { @@ -35,7 +35,7 @@ "username": "Nazwa u\u017cytkownika" }, "description": "R\u0119cznie okre\u015bl adres URL dla swojego regionu", - "title": "Po\u0142\u0105czenie z niestandardowym adresem URL Ezviz" + "title": "Po\u0142\u0105czenie z niestandardowym adresem URL EZVIZ" } } }, diff --git a/homeassistant/components/ezviz/translations/pt-BR.json b/homeassistant/components/ezviz/translations/pt-BR.json index 371686bbf98cdf95401153ea7e3a7db6bc6182a1..5b495d36d574390424bf07a7fd7bf6172e1b31f5 100644 --- a/homeassistant/components/ezviz/translations/pt-BR.json +++ b/homeassistant/components/ezviz/translations/pt-BR.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_account": "A conta j\u00e1 foi configurada", - "ezviz_cloud_account_missing": "Conta na nuvem Ezviz ausente. Por favor, reconfigure a conta de nuvem Ezviz", + "ezviz_cloud_account_missing": "Conta na nuvem EZVIZ ausente. Por favor, reconfigure a conta de nuvem EZVIZ", "unknown": "Erro inesperado" }, "error": { @@ -17,8 +17,8 @@ "password": "Senha", "username": "Usu\u00e1rio" }, - "description": "Insira as credenciais RTSP para a c\u00e2mera Ezviz {serial} com IP {ip_address}", - "title": "C\u00e2mera Ezviz descoberta" + "description": "Insira as credenciais RTSP para a c\u00e2mera EZVIZ {serial} com IP {ip_address}", + "title": "C\u00e2mera EZVIZ descoberta" }, "user": { "data": { @@ -26,7 +26,7 @@ "url": "URL", "username": "Usu\u00e1rio" }, - "title": "Conecte-se ao Ezviz Cloud" + "title": "Conecte-se a EZVIZ Cloud" }, "user_custom_url": { "data": { @@ -35,7 +35,7 @@ "username": "Usu\u00e1rio" }, "description": "Especifique manualmente o URL da sua regi\u00e3o", - "title": "Conecte-se ao URL personalizado do Ezviz" + "title": "Conecte-se a URL personalizado do EZVIZ" } } }, diff --git a/homeassistant/components/ezviz/translations/ru.json b/homeassistant/components/ezviz/translations/ru.json index c03bbe22daebfd378781beedc82fabc60c876d8c..13bdf601817afe76f5e17f077f2383d7642c68ef 100644 --- a/homeassistant/components/ezviz/translations/ru.json +++ b/homeassistant/components/ezviz/translations/ru.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_account": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", - "ezviz_cloud_account_missing": "\u041e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0443\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c Ezviz Cloud. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0435\u0451.", + "ezviz_cloud_account_missing": "\u041e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0443\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c EZVIZ Cloud. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0435\u0451.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { @@ -17,8 +17,8 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 RTSP \u0434\u043b\u044f \u043a\u0430\u043c\u0435\u0440\u044b Ezviz {serial} \u0441 IP-\u0430\u0434\u0440\u0435\u0441\u043e\u043c {ip_address}", - "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u0430\u044f \u043a\u0430\u043c\u0435\u0440\u0430 Ezviz" + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 RTSP \u0434\u043b\u044f \u043a\u0430\u043c\u0435\u0440\u044b EZVIZ {serial} \u0441 IP-\u0430\u0434\u0440\u0435\u0441\u043e\u043c {ip_address}", + "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u0430\u044f \u043a\u0430\u043c\u0435\u0440\u0430 EZVIZ" }, "user": { "data": { @@ -26,7 +26,7 @@ "url": "URL-\u0430\u0434\u0440\u0435\u0441", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a Ezviz Cloud" + "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a EZVIZ Cloud" }, "user_custom_url": { "data": { @@ -35,7 +35,7 @@ "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, "description": "\u0423\u043a\u0430\u0436\u0438\u0442\u0435 URL-\u0430\u0434\u0440\u0435\u0441 \u0434\u043b\u044f \u0412\u0430\u0448\u0435\u0433\u043e \u0440\u0435\u0433\u0438\u043e\u043d\u0430 \u0432\u0440\u0443\u0447\u043d\u0443\u044e.", - "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u043e\u043c\u0443 URL-\u0430\u0434\u0440\u0435\u0441\u0443 Ezviz" + "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u043e\u043c\u0443 URL-\u0430\u0434\u0440\u0435\u0441\u0443 EZVIZ" } } }, diff --git a/homeassistant/components/ezviz/translations/tr.json b/homeassistant/components/ezviz/translations/tr.json index a1ba775da7f8e75e7e651ead398aea1e037c25bb..ecd7ce78f42b847bd2efba9bc1118c167f4fe063 100644 --- a/homeassistant/components/ezviz/translations/tr.json +++ b/homeassistant/components/ezviz/translations/tr.json @@ -26,7 +26,7 @@ "url": "URL", "username": "Kullan\u0131c\u0131 Ad\u0131" }, - "title": "Ezviz Cloud'a ba\u011flan\u0131n" + "title": "EZVIZ Cloud'a ba\u011flan\u0131n" }, "user_custom_url": { "data": { @@ -35,7 +35,7 @@ "username": "Kullan\u0131c\u0131 Ad\u0131" }, "description": "B\u00f6lge URL'nizi manuel olarak belirtin", - "title": "\u00d6zel Ezviz URL'sine ba\u011flan\u0131n" + "title": "\u00d6zel EZVIZ URL'sine ba\u011flan\u0131n" } } }, diff --git a/homeassistant/components/ezviz/translations/zh-Hant.json b/homeassistant/components/ezviz/translations/zh-Hant.json index 84c5daf14c35d4d05744862109d4fe0ebb907294..85c474e64849d468ca6526778705ccd254624fc9 100644 --- a/homeassistant/components/ezviz/translations/zh-Hant.json +++ b/homeassistant/components/ezviz/translations/zh-Hant.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_account": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "ezviz_cloud_account_missing": "\u627e\u4e0d\u5230 Ezviz \u96f2\u5e33\u865f\u3002\u8acb\u91cd\u65b0\u8a2d\u5b9a Ezviz \u96f2\u5e33\u865f", + "ezviz_cloud_account_missing": "\u627e\u4e0d\u5230 EZVIZ \u96f2\u5e33\u865f\u3002\u8acb\u91cd\u65b0\u8a2d\u5b9a EZVIZ \u96f2\u5e33\u865f", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "error": { @@ -17,8 +17,8 @@ "password": "\u5bc6\u78bc", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, - "description": "\u8f38\u5165 IP \u70ba {ip_address} \u7684 Ezviz \u651d\u5f71\u6a5f {serial} RTSP \u6191\u8b49", - "title": "\u81ea\u52d5\u641c\u7d22\u5230\u7684 Ezviz \u651d\u5f71\u6a5f" + "description": "\u8f38\u5165 IP \u70ba {ip_address} \u7684 EZVIZ \u651d\u5f71\u6a5f {serial} RTSP \u6191\u8b49", + "title": "\u81ea\u52d5\u641c\u7d22\u5230\u7684 EZVIZ \u651d\u5f71\u6a5f" }, "user": { "data": { @@ -26,7 +26,7 @@ "url": "\u7db2\u5740", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, - "title": "\u9023\u7dda\u81f3 Ezviz \u87a2\u77f3\u96f2" + "title": "\u9023\u7dda\u81f3 EZVIZ \u87a2\u77f3\u96f2" }, "user_custom_url": { "data": { @@ -35,7 +35,7 @@ "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, "description": "\u624b\u52d5\u6307\u5b9a\u5340\u57df URL", - "title": "\u9023\u7dda\u81f3\u81ea\u8a02 Ezviz URL" + "title": "\u9023\u7dda\u81f3\u81ea\u8a02 EZVIZ URL" } } }, diff --git a/homeassistant/components/faa_delays/translations/nb.json b/homeassistant/components/faa_delays/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/faa_delays/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 08ee465810765544d94a664c9947e43c1d4fd8bb..3661721810b08401eace0edfd33eb22fc5c918ad 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -651,7 +651,13 @@ class FibaroDevice(Entity): self.fibaro_device.properties.batteryLevel ) if "armed" in self.fibaro_device.properties: - attr[ATTR_ARMED] = self.fibaro_device.properties.armed.lower() == "true" + armed = self.fibaro_device.properties.armed + if isinstance(armed, bool): + attr[ATTR_ARMED] = armed + elif isinstance(armed, str) and armed.lower() in ("true", "false"): + attr[ATTR_ARMED] = armed.lower() == "true" + else: + attr[ATTR_ARMED] = None except (ValueError, KeyError): pass diff --git a/homeassistant/components/fibaro/translations/nb.json b/homeassistant/components/fibaro/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/fibaro/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fibaro/translations/no.json b/homeassistant/components/fibaro/translations/no.json index f98835533f577596fae4484cf4a9943184fd5eed..8a52c2de4abf72befe9ce2748404782907069a1e 100644 --- a/homeassistant/components/fibaro/translations/no.json +++ b/homeassistant/components/fibaro/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 2e2ccd8e6b65fb637a521b7e942da1412c50801f..dc6b40982b7f653581e2d8be98f16fa446eeda31 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -3,10 +3,12 @@ from __future__ import annotations from collections import namedtuple from datetime import timedelta +from functools import cached_property import logging from typing import Any from fints.client import FinTS3PinTanClient +from fints.models import SEPAAccount import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -77,7 +79,7 @@ def setup_platform( acc[CONF_ACCOUNT]: acc[CONF_NAME] for acc in config[CONF_HOLDINGS] } - client = FinTsClient(credentials, fints_name) + client = FinTsClient(credentials, fints_name, account_config, holdings_config) balance_accounts, holdings_accounts = client.detect_accounts() accounts: list[SensorEntity] = [] @@ -115,21 +117,27 @@ class FinTsClient: Use this class as Context Manager to get the FinTS3Client object. """ - def __init__(self, credentials: BankCredentials, name: str) -> None: + def __init__( + self, + credentials: BankCredentials, + name: str, + account_config: dict, + holdings_config: dict, + ) -> None: """Initialize a FinTsClient.""" self._credentials = credentials + self._account_information: dict[str, dict] = {} + self._account_information_fetched = False self.name = name + self.account_config = account_config + self.holdings_config = holdings_config - @property - def client(self): - """Get the client object. - - 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. + @cached_property + def client(self) -> FinTS3PinTanClient: + """Get the FinTS client object. - Note: As of version 2, the fints library is not stateless anymore. - This should be considered when reworking this integration. + The FinTS library persists the current dialog with the bank + and stores bank capabilities. So caching the client is beneficial. """ return FinTS3PinTanClient( @@ -139,26 +147,77 @@ class FinTsClient: self._credentials.url, ) - def detect_accounts(self): + def get_account_information(self, iban: str) -> dict | None: + """Get a dictionary of account IBANs as key and account information as value.""" + + if not self._account_information_fetched: + self._account_information = { + account["iban"]: account + for account in self.client.get_information()["accounts"] + } + self._account_information_fetched = True + + return self._account_information.get(iban, None) + + def is_balance_account(self, account: SEPAAccount) -> bool: + """Determine if the given account is of type balance account.""" + if not account.iban: + return False + + account_information = self.get_account_information(account.iban) + if not account_information: + return False + + if not account_information["type"]: + # bank does not support account types, use value from config + if ( + account_information["iban"] in self.account_config + or account_information["account_number"] in self.account_config + ): + return True + elif 1 <= account_information["type"] <= 9: + return True + + return False + + def is_holdings_account(self, account: SEPAAccount) -> bool: + """Determine if the given account of type holdings account.""" + if not account.iban: + return False + + account_information = self.get_account_information(account.iban) + if not account_information: + return False + + if not account_information["type"]: + # bank does not support account types, use value from config + if ( + account_information["iban"] in self.holdings_config + or account_information["account_number"] in self.holdings_config + ): + return True + elif 30 <= account_information["type"] <= 39: + return True + + return False + + def detect_accounts(self) -> tuple[list, list]: """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 accounts: - account_type = account_types[account.iban] - if 1 <= account_type <= 9: # 1-9 is balance account + + for account in self.client.get_sepa_accounts(): + + if self.is_balance_account(account): balance_accounts.append(account) - elif 30 <= account_type <= 39: # 30-39 is holdings account + + elif self.is_holdings_account(account): holdings_accounts.append(account) + else: + _LOGGER.warning("Could not determine type of account %s", account.iban) + return balance_accounts, holdings_accounts diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index 36455da9fb7d0f1fb797e945f9e4c3f86ebb5a9a..1484ff7f1543a153fa3af58b8b7a89fe7d0dc6dc 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -79,6 +79,7 @@ class IncidentsSensor(RestoreEntity, SensorEntity): "type", "responder_mode", "can_respond_until", + "task_ids", ): if data.get(value): attr[value] = data[value] diff --git a/homeassistant/components/fireservicerota/translations/no.json b/homeassistant/components/fireservicerota/translations/no.json index be485577e65aeaa8796ed4d4f7b03d97de086d72..03ecc365e743a126b21a718e5e9b112a1fc62363 100644 --- a/homeassistant/components/fireservicerota/translations/no.json +++ b/homeassistant/components/fireservicerota/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "create_entry": { "default": "Vellykket godkjenning" diff --git a/homeassistant/components/firmata/binary_sensor.py b/homeassistant/components/firmata/binary_sensor.py index 97b6581c4208d4ab8291c3a9b9ab8347a64c97dc..0cb38d1df8b8e7635ac0be966d02a758bb8d47e5 100644 --- a/homeassistant/components/firmata/binary_sensor.py +++ b/homeassistant/components/firmata/binary_sensor.py @@ -40,8 +40,7 @@ async def async_setup_entry( binary_sensor_entity = FirmataBinarySensor(api, config_entry, name, pin) new_entities.append(binary_sensor_entity) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) class FirmataBinarySensor(FirmataPinEntity, BinarySensorEntity): diff --git a/homeassistant/components/firmata/light.py b/homeassistant/components/firmata/light.py index 59be2484d662848c2218ac82a388f9f9103c32f8..29504f704bf24ca76946b89f2e573f143ec16b28 100644 --- a/homeassistant/components/firmata/light.py +++ b/homeassistant/components/firmata/light.py @@ -46,8 +46,7 @@ async def async_setup_entry( light_entity = FirmataLight(api, config_entry, name, pin) new_entities.append(light_entity) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) class FirmataLight(FirmataPinEntity, LightEntity): diff --git a/homeassistant/components/firmata/sensor.py b/homeassistant/components/firmata/sensor.py index 359090fae1c0d92cae6aec1baebf311e587895a1..3497aec9e880d019b3517a0ee886ee4d7834f023 100644 --- a/homeassistant/components/firmata/sensor.py +++ b/homeassistant/components/firmata/sensor.py @@ -40,8 +40,7 @@ async def async_setup_entry( sensor_entity = FirmataSensor(api, config_entry, name, pin) new_entities.append(sensor_entity) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) class FirmataSensor(FirmataPinEntity, SensorEntity): diff --git a/homeassistant/components/firmata/switch.py b/homeassistant/components/firmata/switch.py index 10037bb6a7ab2c2b0e46e6f4f48c90fc13289076..f52300b6db30e1d00f65d7510e0d86f77476dd8a 100644 --- a/homeassistant/components/firmata/switch.py +++ b/homeassistant/components/firmata/switch.py @@ -42,8 +42,7 @@ async def async_setup_entry( switch_entity = FirmataSwitch(api, config_entry, name, pin) new_entities.append(switch_entity) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) class FirmataSwitch(FirmataPinEntity, SwitchEntity): diff --git a/homeassistant/components/firmata/translations/tr.json b/homeassistant/components/firmata/translations/tr.json index b7d038a229b0dfd3dbf83da9d4e5a14bf45eb954..1e7302b9096f9d1fd75bc13f72f09ef2a4a49875 100644 --- a/homeassistant/components/firmata/translations/tr.json +++ b/homeassistant/components/firmata/translations/tr.json @@ -2,6 +2,10 @@ "config": { "abort": { "cannot_connect": "Ba\u011flanma hatas\u0131" + }, + "step": { + "one": "Bo\u015f", + "other": "Bo\u015f" } } } \ No newline at end of file diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 3165843a23d80013a36be57c6c691773aa2c688d..78929307c02cb595cd8ba1f90562e3dc2008c21f 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -19,12 +19,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, SensorEntity, ) -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_UNIT_SYSTEM, -) +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_UNIT_SYSTEM from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -32,6 +27,7 @@ from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import load_json, save_json +from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( ATTR_ACCESS_TOKEN, @@ -90,7 +86,7 @@ def request_app_setup( if os.path.isfile(config_path): config_file = load_json(config_path) if config_file == DEFAULT_CONFIG: - error_msg = "You didn't correctly modify fitbit.conf, please try again." + error_msg = f"You didn't correctly modify {FITBIT_CONFIG_FILE}, please try again." configurator.notify_errors(hass, _CONFIGURING["fitbit"], error_msg) else: @@ -115,7 +111,7 @@ def request_app_setup( ) return - submit = "I have saved my Client ID and Client Secret into fitbit.conf." + submit = f"I have saved my Client ID and Client Secret into {FITBIT_CONFIG_FILE}." _CONFIGURING["fitbit"] = configurator.request_config( hass, @@ -195,10 +191,11 @@ def setup_platform( if int(time.time()) - expires_at > 3600: authd_client.client.refresh_token() + user_profile = authd_client.user_profile_get()["user"] if (unit_system := config[CONF_UNIT_SYSTEM]) == "default": - authd_client.system = authd_client.user_profile_get()["user"]["locale"] + authd_client.system = user_profile["locale"] if authd_client.system != "en_GB": - if hass.config.units.is_metric: + if hass.config.units is METRIC_SYSTEM: authd_client.system = "metric" else: authd_client.system = "en_US" @@ -211,9 +208,10 @@ def setup_platform( entities = [ FitbitSensor( authd_client, + user_profile, config_path, description, - hass.config.units.is_metric, + hass.config.units is METRIC_SYSTEM, clock_format, ) for description in FITBIT_RESOURCES_LIST @@ -224,9 +222,10 @@ def setup_platform( [ FitbitSensor( authd_client, + user_profile, config_path, FITBIT_RESOURCE_BATTERY, - hass.config.units.is_metric, + hass.config.units is METRIC_SYSTEM, clock_format, dev_extra, ) @@ -341,10 +340,12 @@ class FitbitSensor(SensorEntity): """Implementation of a Fitbit sensor.""" entity_description: FitbitSensorEntityDescription + _attr_attribution = ATTRIBUTION def __init__( self, client: Fitbit, + user_profile: dict[str, Any], config_path: str, description: FitbitSensorEntityDescription, is_metric: bool, @@ -358,8 +359,12 @@ class FitbitSensor(SensorEntity): self.is_metric = is_metric self.clock_format = clock_format self.extra = extra + + self._attr_unique_id = f"{user_profile['encodedId']}_{description.key}" if self.extra is not None: self._attr_name = f"{self.extra.get('deviceVersion')} Battery" + self._attr_unique_id = f"{self._attr_unique_id}_{self.extra.get('id')}" + if (unit_type := description.unit_type) == "": split_resource = description.key.rsplit("/", maxsplit=1)[-1] try: @@ -387,7 +392,7 @@ class FitbitSensor(SensorEntity): @property def extra_state_attributes(self) -> dict[str, str | None]: """Return the state attributes.""" - attrs: dict[str, str | None] = {ATTR_ATTRIBUTION: ATTRIBUTION} + attrs: dict[str, str | None] = {} if self.extra is not None: attrs["model"] = self.extra.get("deviceVersion") diff --git a/homeassistant/components/fivem/translations/nb.json b/homeassistant/components/fivem/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..4518f3cd8cb8990549d040876ed38f1c26b6c848 --- /dev/null +++ b/homeassistant/components/fivem/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown_error": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fjaraskupan/manifest.json b/homeassistant/components/fjaraskupan/manifest.json index 7381fc36a084fbcc831ffa47eab779998182aedb..443991b5d701dd154b959f7aa431add67c91954e 100644 --- a/homeassistant/components/fjaraskupan/manifest.json +++ b/homeassistant/components/fjaraskupan/manifest.json @@ -3,7 +3,7 @@ "name": "Fj\u00e4r\u00e5skupan", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fjaraskupan", - "requirements": ["fjaraskupan==2.0.0"], + "requirements": ["fjaraskupan==2.2.0"], "codeowners": ["@elupus"], "iot_class": "local_polling", "loggers": ["bleak", "fjaraskupan"], diff --git a/homeassistant/components/flexom/manifest.json b/homeassistant/components/flexom/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..9242a48af4886fe4e9995cc1d5aa801bb3205564 --- /dev/null +++ b/homeassistant/components/flexom/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "flexom", + "name": "Bouygues Flexom", + "integration_type": "virtual", + "supported_by": "overkiz" +} diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index 215c9a88f6b76e8212a2cbfbbc60e93789c90596..d0ff91d70945b008b9d5a13150f9794d8b821937 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -8,12 +8,7 @@ from pyflick import FlickAPI, FlickPrice from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_FRIENDLY_NAME, - CURRENCY_CENT, - ENERGY_KILO_WATT_HOUR, -) +from homeassistant.const import ATTR_FRIENDLY_NAME, CURRENCY_CENT, ENERGY_KILO_WATT_HOUR from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow @@ -24,7 +19,6 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=5) -ATTRIBUTION = "Data provided by Flick Electric" FRIENDLY_NAME = "Flick Power Price" @@ -40,6 +34,7 @@ async def async_setup_entry( class FlickPricingSensor(SensorEntity): """Entity object for Flick Electric sensor.""" + _attr_attribution = "Data provided by Flick Electric" _attr_native_unit_of_measurement = f"{CURRENCY_CENT}/{ENERGY_KILO_WATT_HOUR}" def __init__(self, api: FlickAPI) -> None: @@ -47,7 +42,6 @@ class FlickPricingSensor(SensorEntity): self._api: FlickAPI = api self._price: FlickPrice = None self._attributes: dict[str, Any] = { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_FRIENDLY_NAME: FRIENDLY_NAME, } diff --git a/homeassistant/components/flick_electric/translations/nb.json b/homeassistant/components/flick_electric/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/flick_electric/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/bg.json b/homeassistant/components/flipr/translations/bg.json index 4e79121f56b3b6b6717a61c949bc60a8e123a8d6..fbc626a83d741de06188524aa08576b345b5426a 100644 --- a/homeassistant/components/flipr/translations/bg.json +++ b/homeassistant/components/flipr/translations/bg.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "email": "Email", + "email": "\u0418\u043c\u0435\u0439\u043b", "password": "\u041f\u0430\u0440\u043e\u043b\u0430" }, "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Flipr" diff --git a/homeassistant/components/flipr/translations/nb.json b/homeassistant/components/flipr/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/flipr/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py index 9f793b749e46000636effcb538e4413ae396419d..9cb070a1c62870056b256ad1e208c4528a8b4d22 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -71,6 +71,7 @@ class FloDailyUsageSensor(FloEntity, SensorEntity): _attr_icon = WATER_ICON _attr_native_unit_of_measurement = VOLUME_GALLONS _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING + _attr_device_class = SensorDeviceClass.WATER def __init__(self, device): """Initialize the daily water usage sensor.""" diff --git a/homeassistant/components/flo/translations/nb.json b/homeassistant/components/flo/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/flo/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..2f5ea4323d9e12acbf4c7585ead0aae1edd3a05e --- /dev/null +++ b/homeassistant/components/flume/binary_sensor.py @@ -0,0 +1,160 @@ +"""Flume binary sensors.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + FLUME_AUTH, + FLUME_DEVICES, + FLUME_TYPE_BRIDGE, + FLUME_TYPE_SENSOR, + KEY_DEVICE_ID, + KEY_DEVICE_LOCATION, + KEY_DEVICE_LOCATION_NAME, + KEY_DEVICE_TYPE, + NOTIFICATION_HIGH_FLOW, + NOTIFICATION_LEAK_DETECTED, +) +from .coordinator import ( + FlumeDeviceConnectionUpdateCoordinator, + FlumeNotificationDataUpdateCoordinator, +) +from .entity import FlumeEntity +from .util import get_valid_flume_devices + +BINARY_SENSOR_DESCRIPTION_CONNECTED = BinarySensorEntityDescription( + name="Connected", + key="connected", +) + + +@dataclass +class FlumeBinarySensorRequiredKeysMixin: + """Mixin for required keys.""" + + event_rule: str + + +@dataclass +class FlumeBinarySensorEntityDescription( + BinarySensorEntityDescription, FlumeBinarySensorRequiredKeysMixin +): + """Describes a binary sensor entity.""" + + +FLUME_BINARY_NOTIFICATION_SENSORS: tuple[FlumeBinarySensorEntityDescription, ...] = ( + FlumeBinarySensorEntityDescription( + key="leak", + name="Leak detected", + entity_category=EntityCategory.DIAGNOSTIC, + event_rule=NOTIFICATION_LEAK_DETECTED, + icon="mdi:pipe-leak", + ), + FlumeBinarySensorEntityDescription( + key="flow", + name="High flow", + entity_category=EntityCategory.DIAGNOSTIC, + event_rule=NOTIFICATION_HIGH_FLOW, + icon="mdi:waves", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Flume binary sensor..""" + flume_domain_data = hass.data[DOMAIN][config_entry.entry_id] + flume_auth = flume_domain_data[FLUME_AUTH] + flume_devices = flume_domain_data[FLUME_DEVICES] + + flume_entity_list: list[ + FlumeNotificationBinarySensor | FlumeConnectionBinarySensor + ] = [] + + connection_coordinator = FlumeDeviceConnectionUpdateCoordinator( + hass=hass, flume_devices=flume_devices + ) + notification_coordinator = FlumeNotificationDataUpdateCoordinator( + hass=hass, auth=flume_auth + ) + flume_devices = get_valid_flume_devices(flume_devices) + for device in flume_devices: + device_id = device[KEY_DEVICE_ID] + device_location_name = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_NAME] + + connection_sensor = FlumeConnectionBinarySensor( + coordinator=connection_coordinator, + description=BINARY_SENSOR_DESCRIPTION_CONNECTED, + device_id=device_id, + location_name=device_location_name, + is_bridge=(device[KEY_DEVICE_TYPE] is FLUME_TYPE_BRIDGE), + ) + + flume_entity_list.append(connection_sensor) + + if device[KEY_DEVICE_TYPE] != FLUME_TYPE_SENSOR: + continue + + # Build notification sensors + flume_entity_list.extend( + [ + FlumeNotificationBinarySensor( + coordinator=notification_coordinator, + description=description, + device_id=device_id, + location_name=device_location_name, + ) + for description in FLUME_BINARY_NOTIFICATION_SENSORS + ] + ) + + async_add_entities(flume_entity_list) + + +class FlumeNotificationBinarySensor(FlumeEntity, BinarySensorEntity): + """Binary sensor class.""" + + entity_description: FlumeBinarySensorEntityDescription + coordinator: FlumeNotificationDataUpdateCoordinator + + @property + def is_on(self) -> bool: + """Return on state.""" + return bool( + ( + notifications := self.coordinator.active_notifications_by_device.get( + self.device_id + ) + ) + and self.entity_description.event_rule in notifications + ) + + +class FlumeConnectionBinarySensor(FlumeEntity, BinarySensorEntity): + """Binary Sensor class for WIFI Connection status.""" + + entity_description: FlumeBinarySensorEntityDescription + coordinator: FlumeDeviceConnectionUpdateCoordinator + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + + @property + def is_on(self) -> bool: + """Return connection status.""" + return bool( + (connected := self.coordinator.connected) and connected[self.device_id] + ) diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py index 5e095961ed8804e5e1a6ed01e641cb500ccb447d..2d53db4c4865b4abb55cf5335d623813c99d6a4a 100644 --- a/homeassistant/components/flume/const.py +++ b/homeassistant/components/flume/const.py @@ -4,58 +4,27 @@ from __future__ import annotations from datetime import timedelta import logging -from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import Platform DOMAIN = "flume" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] DEFAULT_NAME = "Flume Sensor" +# Flume API limits individual endpoints to 120 queries per hour NOTIFICATION_SCAN_INTERVAL = timedelta(minutes=1) DEVICE_SCAN_INTERVAL = timedelta(minutes=1) +DEVICE_CONNECTION_SCAN_INTERVAL = timedelta(minutes=1) _LOGGER = logging.getLogger(__package__) +FLUME_TYPE_BRIDGE = 1 FLUME_TYPE_SENSOR = 2 -FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="current_interval", - name="Current", - native_unit_of_measurement="gal/m", - ), - SensorEntityDescription( - key="month_to_date", - name="Current Month", - native_unit_of_measurement="gal", - ), - SensorEntityDescription( - key="week_to_date", - name="Current Week", - native_unit_of_measurement="gal", - ), - SensorEntityDescription( - key="today", - name="Current Day", - native_unit_of_measurement="gal", - ), - SensorEntityDescription( - key="last_60_min", - name="60 Minutes", - native_unit_of_measurement="gal/h", - ), - SensorEntityDescription( - key="last_24_hrs", - name="24 Hours", - native_unit_of_measurement="gal/d", - ), - SensorEntityDescription( - key="last_30_days", - name="30 Days", - native_unit_of_measurement="gal/mo", - ), -) + FLUME_AUTH = "flume_auth" FLUME_HTTP_SESSION = "http_session" @@ -71,3 +40,10 @@ KEY_DEVICE_ID = "id" KEY_DEVICE_LOCATION = "location" KEY_DEVICE_LOCATION_NAME = "name" KEY_DEVICE_LOCATION_TIMEZONE = "tz" + + +NOTIFICATION_HIGH_FLOW = "High Flow Alert" +NOTIFICATION_BRIDGE_DISCONNECT = "Bridge Disconnection" +BRIDGE_NOTIFICATION_KEY = "connected" +BRIDGE_NOTIFICATION_RULE = "Bridge Disconnection" +NOTIFICATION_LEAK_DETECTED = "Flume Smart Leak Alert" diff --git a/homeassistant/components/flume/coordinator.py b/homeassistant/components/flume/coordinator.py index 9e23141cd5e54a7c6b9f6e730d9eaaa0211decd4..70a99f56968b575759ced255ebb8e9bd8306b752 100644 --- a/homeassistant/components/flume/coordinator.py +++ b/homeassistant/components/flume/coordinator.py @@ -1,10 +1,21 @@ """The IntelliFire integration.""" from __future__ import annotations +from typing import Any + +import pyflume +from pyflume import FlumeDeviceList + from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import _LOGGER, DEVICE_SCAN_INTERVAL, DOMAIN +from .const import ( + _LOGGER, + DEVICE_CONNECTION_SCAN_INTERVAL, + DEVICE_SCAN_INTERVAL, + DOMAIN, + NOTIFICATION_SCAN_INTERVAL, +) class FlumeDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): @@ -23,13 +34,89 @@ class FlumeDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Get the latest data from the Flume.""" - _LOGGER.debug("Updating Flume data") try: await self.hass.async_add_executor_job(self.flume_device.update_force) except Exception as ex: raise UpdateFailed(f"Error communicating with flume API: {ex}") from ex _LOGGER.debug( - "Flume update details: values=%s query_payload=%s", + "Flume Device Data Update values=%s query_payload=%s", self.flume_device.values, self.flume_device.query_payload, ) + + +class FlumeDeviceConnectionUpdateCoordinator(DataUpdateCoordinator[None]): + """Date update coordinator to read connected status from Devices endpoint.""" + + def __init__(self, hass: HomeAssistant, flume_devices: FlumeDeviceList) -> None: + """Initialize the Coordinator.""" + super().__init__( + hass, + name=DOMAIN, + logger=_LOGGER, + update_interval=DEVICE_CONNECTION_SCAN_INTERVAL, + ) + + self.flume_devices = flume_devices + self.connected: dict[str, bool] = {} + + def _update_connectivity(self) -> None: + """Update device connectivity..""" + self.connected = { + device["id"]: device["connected"] + for device in self.flume_devices.get_devices() + } + _LOGGER.debug("Connectivity %s", self.connected) + + async def _async_update_data(self) -> None: + """Update the device list.""" + try: + await self.hass.async_add_executor_job(self._update_connectivity) + except Exception as ex: + raise UpdateFailed(f"Error communicating with flume API: {ex}") from ex + + +class FlumeNotificationDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Data update coordinator for flume notifications.""" + + def __init__(self, hass: HomeAssistant, auth) -> None: + """Initialize the Coordinator.""" + super().__init__( + hass, + name=DOMAIN, + logger=_LOGGER, + update_interval=NOTIFICATION_SCAN_INTERVAL, + ) + self.auth = auth + self.active_notifications_by_device: dict = {} + self.notifications: list[dict[str, Any]] + + def _update_lists(self): + """Query flume for notification list.""" + self.notifications: list[dict[str, Any]] = pyflume.FlumeNotificationList( + self.auth, read="true" + ).notification_list + _LOGGER.debug("Notifications %s", self.notifications) + + active_notifications_by_device: dict[str, set[str]] = {} + + for notification in self.notifications: + if ( + not notification.get("device_id") + or not notification.get("extra") + or "event_rule_name" not in notification["extra"] + ): + continue + device_id = notification["device_id"] + rule = notification["extra"]["event_rule_name"] + active_notifications_by_device.setdefault(device_id, set()).add(rule) + + self.active_notifications_by_device = active_notifications_by_device + + async def _async_update_data(self) -> None: + """Update data.""" + _LOGGER.debug("Updating Flume Notification") + try: + await self.hass.async_add_executor_job(self._update_lists) + except Exception as ex: + raise UpdateFailed(f"Error communicating with flume API: {ex}") from ex diff --git a/homeassistant/components/flume/entity.py b/homeassistant/components/flume/entity.py index b36ecd28cf81a72b6f1bd1f986381c4354969a49..7cd84127c647e7083584e5621876fa8faa4773c5 100644 --- a/homeassistant/components/flume/entity.py +++ b/homeassistant/components/flume/entity.py @@ -2,13 +2,15 @@ from __future__ import annotations from homeassistant.helpers.entity import DeviceInfo, EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import DOMAIN -from .coordinator import FlumeDeviceDataUpdateCoordinator -class FlumeEntity(CoordinatorEntity[FlumeDeviceDataUpdateCoordinator]): +class FlumeEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): """Base entity class.""" _attr_attribution = "Data provided by Flume API" @@ -16,20 +18,29 @@ class FlumeEntity(CoordinatorEntity[FlumeDeviceDataUpdateCoordinator]): def __init__( self, - coordinator: FlumeDeviceDataUpdateCoordinator, + coordinator: DataUpdateCoordinator, description: EntityDescription, device_id: str, + location_name: str, + is_bridge: bool = False, ) -> None: """Class initializer.""" super().__init__(coordinator) self.entity_description = description self.device_id = device_id + + if is_bridge: + name = "Flume Bridge" + else: + name = "Flume Sensor" + self._attr_unique_id = f"{description.key}_{device_id}" + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_id)}, manufacturer="Flume, Inc.", model="Flume Smart Water Monitor", - name=f"Flume {device_id}", + name=f"{name} {location_name}", configuration_url="https://portal.flumewater.com", ) diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 6d68058732dcf4dbe85bd3646a71581d50e55f13..5b8dbde69e4cdd81cc5da84e643d41efbb6f693f 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -3,8 +3,14 @@ from numbers import Number from pyflume import FlumeData -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import VOLUME_GALLONS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -14,15 +20,63 @@ from .const import ( FLUME_AUTH, FLUME_DEVICES, FLUME_HTTP_SESSION, - FLUME_QUERIES_SENSOR, FLUME_TYPE_SENSOR, KEY_DEVICE_ID, KEY_DEVICE_LOCATION, + KEY_DEVICE_LOCATION_NAME, KEY_DEVICE_LOCATION_TIMEZONE, KEY_DEVICE_TYPE, ) from .coordinator import FlumeDeviceDataUpdateCoordinator from .entity import FlumeEntity +from .util import get_valid_flume_devices + +FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="current_interval", + name="Current", + native_unit_of_measurement=f"{VOLUME_GALLONS}/m", + ), + SensorEntityDescription( + key="month_to_date", + name="Current Month", + native_unit_of_measurement=VOLUME_GALLONS, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="week_to_date", + name="Current Week", + native_unit_of_measurement=VOLUME_GALLONS, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="today", + name="Current Day", + native_unit_of_measurement=VOLUME_GALLONS, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="last_60_min", + name="60 Minutes", + native_unit_of_measurement=f"{VOLUME_GALLONS}/h", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="last_24_hrs", + name="24 Hours", + native_unit_of_measurement=f"{VOLUME_GALLONS}/d", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="last_30_days", + name="30 Days", + native_unit_of_measurement=f"{VOLUME_GALLONS}/mo", + state_class=SensorStateClass.MEASUREMENT, + ), +) async def async_setup_entry( @@ -33,21 +87,20 @@ async def async_setup_entry( """Set up the Flume sensor.""" flume_domain_data = hass.data[DOMAIN][config_entry.entry_id] - + flume_devices = flume_domain_data[FLUME_DEVICES] flume_auth = flume_domain_data[FLUME_AUTH] http_session = flume_domain_data[FLUME_HTTP_SESSION] - flume_devices = flume_domain_data[FLUME_DEVICES] - + flume_devices = [ + device + for device in get_valid_flume_devices(flume_devices) + if device[KEY_DEVICE_TYPE] == FLUME_TYPE_SENSOR + ] flume_entity_list = [] - for device in flume_devices.device_list: - if ( - device[KEY_DEVICE_TYPE] != FLUME_TYPE_SENSOR - or KEY_DEVICE_LOCATION not in device - ): - continue + for device in flume_devices: device_id = device[KEY_DEVICE_ID] device_timezone = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_TIMEZONE] + device_location_name = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_NAME] flume_device = FlumeData( flume_auth, @@ -68,13 +121,13 @@ async def async_setup_entry( coordinator=coordinator, description=description, device_id=device_id, + location_name=device_location_name, ) for description in FLUME_QUERIES_SENSOR ] ) - if flume_entity_list: - async_add_entities(flume_entity_list) + async_add_entities(flume_entity_list) class FlumeSensor(FlumeEntity, SensorEntity): @@ -82,15 +135,6 @@ class FlumeSensor(FlumeEntity, SensorEntity): coordinator: FlumeDeviceDataUpdateCoordinator - def __init__( - self, - coordinator: FlumeDeviceDataUpdateCoordinator, - device_id: str, - description: SensorEntityDescription, - ) -> None: - """Inlitializer function with type hints.""" - super().__init__(coordinator, description, device_id) - @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/flume/translations/bg.json b/homeassistant/components/flume/translations/bg.json index 14aa8f088f3ecdeb3d7136d97e66ab5eda022b03..1ceb53d2be7d3b0259eee28ada69e4b7c37b0d71 100644 --- a/homeassistant/components/flume/translations/bg.json +++ b/homeassistant/components/flume/translations/bg.json @@ -1,7 +1,8 @@ { "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" + "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", @@ -10,6 +11,9 @@ }, "step": { "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + }, "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": { diff --git a/homeassistant/components/flume/translations/nb.json b/homeassistant/components/flume/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/flume/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/no.json b/homeassistant/components/flume/translations/no.json index aeda0eae271b7b417e6878f23d4d9a1a30fa1b69..b0066f9decbc45df66717ec60d3d2cb95047704a 100644 --- a/homeassistant/components/flume/translations/no.json +++ b/homeassistant/components/flume/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/flume/util.py b/homeassistant/components/flume/util.py new file mode 100644 index 0000000000000000000000000000000000000000..b943124b877c5e671a1ade4592bce42ff966ed86 --- /dev/null +++ b/homeassistant/components/flume/util.py @@ -0,0 +1,18 @@ +"""Utilities for Flume.""" + +from __future__ import annotations + +from typing import Any + +from pyflume import FlumeDeviceList + +from .const import KEY_DEVICE_LOCATION, KEY_DEVICE_LOCATION_NAME + + +def get_valid_flume_devices(flume_devices: FlumeDeviceList) -> list[dict[str, Any]]: + """Return a list of Flume devices that have a valid location.""" + return [ + device + for device in flume_devices.device_list + if KEY_DEVICE_LOCATION_NAME in device[KEY_DEVICE_LOCATION] + ] diff --git a/homeassistant/components/flux_led/number.py b/homeassistant/components/flux_led/number.py index 18237c97e94832ac71eb40708701df6b39bbf3a7..4fe0e250db678e72bb3333f6cb123690b01e2c14 100644 --- a/homeassistant/components/flux_led/number.py +++ b/homeassistant/components/flux_led/number.py @@ -89,8 +89,7 @@ async def async_setup_entry( FluxSpeedNumber(coordinator, base_unique_id, f"{name} Effect Speed", None) ) - if entities: - async_add_entities(entities) + async_add_entities(entities) class FluxSpeedNumber( diff --git a/homeassistant/components/flux_led/select.py b/homeassistant/components/flux_led/select.py index 63929740020f936226b1e95b5a6ee8557dec1395..9e24cd2cd7d3d5c05ff061914dd723d4d149c58d 100644 --- a/homeassistant/components/flux_led/select.py +++ b/homeassistant/components/flux_led/select.py @@ -81,8 +81,7 @@ async def async_setup_entry( if FLUX_COLOR_MODE_RGBW in device.color_modes: entities.append(FluxWhiteChannelSelect(coordinator.device, entry)) - if entities: - async_add_entities(entities) + async_add_entities(entities) class FluxConfigAtStartSelect(FluxBaseEntity, SelectEntity): diff --git a/homeassistant/components/flux_led/switch.py b/homeassistant/components/flux_led/switch.py index 18b079beff9b2b133878e004d4e38bd3e4daccb9..168d16268aae8cc68012c4a1a34caf3cc8bce45f 100644 --- a/homeassistant/components/flux_led/switch.py +++ b/homeassistant/components/flux_led/switch.py @@ -48,8 +48,7 @@ async def async_setup_entry( FluxMusicSwitch(coordinator, base_unique_id, f"{name} Music", "music") ) - if entities: - async_add_entities(entities) + async_add_entities(entities) class FluxSwitch( diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 472f5cac2130c81c13bd0c9301a64419fe64e687..a3ae91b4a92f42dc60d169536b20b98c33d412e6 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -6,5 +6,6 @@ "requirements": ["forecast_solar==2.2.0"], "codeowners": ["@klaasnicolaas", "@frenck"], "quality_scale": "platinum", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "integration_type": "service" } diff --git a/homeassistant/components/forked_daapd/browse_media.py b/homeassistant/components/forked_daapd/browse_media.py index 099a042f58a52695e6579bfd2f34f9b3e6987131..a4c97d3a035eb52d13bdde1307a0f43e420be659 100644 --- a/homeassistant/components/forked_daapd/browse_media.py +++ b/homeassistant/components/forked_daapd/browse_media.py @@ -172,7 +172,7 @@ async def get_owntone_content( result = await master.api.get_artists() # list of artists with name, uri elif media_content.type == MediaType.GENRE: if result := await master.api.get_genres(): # returns list of genre names - for item in result: # pylint: disable=not-an-iterable + for item in result: # add generated genre uris to list of genre names item["uri"] = create_owntone_uri( MediaType.GENRE, cast(str, item["name"]) diff --git a/homeassistant/components/forked_daapd/const.py b/homeassistant/components/forked_daapd/const.py index 60e31bc707d892c9216e8e8a278ea46c4a551036..f74fe0b049d27d0cc1e8e8fb31276f610f0166d2 100644 --- a/homeassistant/components/forked_daapd/const.py +++ b/homeassistant/components/forked_daapd/const.py @@ -30,7 +30,7 @@ DEFAULT_TTS_PAUSE_TIME = 1.2 DEFAULT_TTS_VOLUME = 0.8 DEFAULT_UNMUTE_VOLUME = 0.6 DOMAIN = "forked_daapd" # key for hass.data -FD_NAME = "forked-daapd" +FD_NAME = "Owntone" HASS_DATA_REMOVE_LISTENERS_KEY = "REMOVE_LISTENERS" HASS_DATA_UPDATER_KEY = "UPDATER" KNOWN_PIPES = {"librespot-java"} diff --git a/homeassistant/components/forked_daapd/manifest.json b/homeassistant/components/forked_daapd/manifest.json index 14d2132a165cd8aec1beb80aa845b5d42e31718b..e6793498c35c82880d2bfb1dcc30cfb97580283a 100644 --- a/homeassistant/components/forked_daapd/manifest.json +++ b/homeassistant/components/forked_daapd/manifest.json @@ -1,9 +1,9 @@ { "domain": "forked_daapd", - "name": "forked-daapd", + "name": "Owntone", "documentation": "https://www.home-assistant.io/integrations/forked_daapd", "codeowners": ["@uvjustin"], - "requirements": ["pyforked-daapd==0.1.11", "pylibrespot-java==0.1.0"], + "requirements": ["pyforked-daapd==0.1.14", "pylibrespot-java==0.1.1"], "after_dependencies": ["spotify"], "config_flow": true, "zeroconf": ["_daap._tcp.local."], diff --git a/homeassistant/components/forked_daapd/strings.json b/homeassistant/components/forked_daapd/strings.json index 671538210ff8667914dc36335c8a675a0b9b6a89..76a03abeb4b1e804383136ff22a71a86cbb13c5d 100644 --- a/homeassistant/components/forked_daapd/strings.json +++ b/homeassistant/components/forked_daapd/strings.json @@ -3,7 +3,7 @@ "flow_title": "{name} ({host})", "step": { "user": { - "title": "Set up forked-daapd device", + "title": "Set up Owntone device", "data": { "name": "Friendly name", "host": "[%key:common::config_flow::data::host%]", @@ -13,23 +13,23 @@ } }, "error": { - "forbidden": "Unable to connect. Please check your forked-daapd network permissions.", - "websocket_not_enabled": "forked-daapd server websocket not enabled.", + "forbidden": "Unable to connect. Please check your Owntone network permissions.", + "websocket_not_enabled": "Owntone server websocket not enabled.", "wrong_host_or_port": "Unable to connect. Please check host and port.", "wrong_password": "Incorrect password.", - "wrong_server_type": "The forked-daapd integration requires a forked-daapd server with version >= 27.0.", + "wrong_server_type": "The Owntone integration requires an Owntone server with version >= 27.0.", "unknown_error": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "not_forked_daapd": "Device is not a forked-daapd server." + "not_forked_daapd": "Device is not an Owntone server." } }, "options": { "step": { "init": { - "title": "Configure forked-daapd options", - "description": "Set various options for the forked-daapd integration.", + "title": "Configure Owntone options", + "description": "Set various options for the Owntone integration.", "data": { "librespot_java_port": "Port for librespot-java pipe control (if used)", "max_playlists": "Max number of playlists used as sources", diff --git a/homeassistant/components/forked_daapd/translations/ca.json b/homeassistant/components/forked_daapd/translations/ca.json index f84b0376dd6380173abe80a40fe415d8a249ff91..e35929915fcb3a1f8a6d6114d55a88e18fa6c82f 100644 --- a/homeassistant/components/forked_daapd/translations/ca.json +++ b/homeassistant/components/forked_daapd/translations/ca.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", - "not_forked_daapd": "El dispositiu no \u00e9s un servidor de forked-daapd." + "not_forked_daapd": "El dispositiu no \u00e9s un servidor Owntone." }, "error": { - "forbidden": "No s'ha pogut connectar. Comprova els permisos de xarxa de forked-daapd.", + "forbidden": "No s'ha pogut connectar. Comprova els permisos de xarxa d'Owntone.", "unknown_error": "Error inesperat", - "websocket_not_enabled": "El websocket de forked-daapd no est\u00e0 activat.", + "websocket_not_enabled": "El websocket d'Owntone no est\u00e0 activat.", "wrong_host_or_port": "No s'ha pogut connectar, verifica l'amfitri\u00f3 i el port.", "wrong_password": "Contrasenya incorrecta.", - "wrong_server_type": "La integraci\u00f3 forked-daapd necessita un servidor forked-daapd amb versi\u00f3 >= 27.0." + "wrong_server_type": "La integraci\u00f3 Owntone necessita un servidor Owntone amb versi\u00f3 >= 27.0." }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "Contrasenya de l'API (deixa-ho en blanc si no t\u00e9 contrasenya)", "port": "Port de l'API" }, - "title": "Configuraci\u00f3 del dispositiu forked-daapd" + "title": "Configuraci\u00f3 de dispositiu Owntone" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "Segons de pausa abans i despr\u00e9s de TTS", "tts_volume": "Volum TTS (valor 'float' entre [0,1])" }, - "description": "Configura les diferents opcions de la integraci\u00f3 forked-daapd.", - "title": "Configuraci\u00f3 de les opcions de forked-daapd" + "description": "Configura les diferents opcions de la integraci\u00f3 Owntone.", + "title": "Configuraci\u00f3 de les opcions d'Owntone" } } } diff --git a/homeassistant/components/forked_daapd/translations/de.json b/homeassistant/components/forked_daapd/translations/de.json index 984358f02ba6f23e913a3572149746a0871f5544..3754d70f449ddaac077676e47c0c3f0d1d1f9a3b 100644 --- a/homeassistant/components/forked_daapd/translations/de.json +++ b/homeassistant/components/forked_daapd/translations/de.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", - "not_forked_daapd": "Das Ger\u00e4t ist kein Forked-Daapd-Server." + "not_forked_daapd": "Das Ger\u00e4t ist kein Owntone-Server." }, "error": { - "forbidden": "Verbindung kann nicht hergestellt werden. Bitte \u00fcberpr\u00fcfe deine forked-daapd-Netzwerkberechtigungen.", + "forbidden": "Verbindung kann nicht hergestellt werden. Bitte \u00fcberpr\u00fcfe deine Owntone-Netzwerkberechtigungen.", "unknown_error": "Unerwarteter Fehler", - "websocket_not_enabled": "Forked-Daapd-Server-Websocket nicht aktiviert.", + "websocket_not_enabled": "Owntone Server Websocket nicht aktiviert.", "wrong_host_or_port": "Verbindung konnte nicht hergestellt werden. Bitte Host und Port pr\u00fcfen.", "wrong_password": "Ung\u00fcltiges Passwort", - "wrong_server_type": "F\u00fcr die forked-daapd Integration ist ein forked-daapd Server mit der Version >= 27.0 erforderlich." + "wrong_server_type": "Die Owntone-Integration erfordert einen Owntone-Server mit Version >= 27.0." }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "API-Passwort (leer lassen, wenn kein Passwort vorhanden ist)", "port": "API Port" }, - "title": "Forked-Daapd-Ger\u00e4t einrichten" + "title": "Owntone Ger\u00e4t einrichten" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "Sekunden bis zur Pause vor und nach der TTS", "tts_volume": "TTS-Lautst\u00e4rke (Float im Bereich [0,1])" }, - "description": "Lege verschiedene Optionen f\u00fcr die Forked-Daapd-Integration fest.", - "title": "Konfigurieren der Forked-Daapd-Optionen" + "description": "Lege verschiedene Optionen f\u00fcr die Owntone-Integration fest.", + "title": "Konfigurieren der Owntone Optionen" } } } diff --git a/homeassistant/components/forked_daapd/translations/en.json b/homeassistant/components/forked_daapd/translations/en.json index cf7a1e5281b40f51963a2ac4dcfc4e9e67367146..b043969a5eaec737c28c0d22b9cf98aae61c3909 100644 --- a/homeassistant/components/forked_daapd/translations/en.json +++ b/homeassistant/components/forked_daapd/translations/en.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "Device is already configured", - "not_forked_daapd": "Device is not a forked-daapd server." + "not_forked_daapd": "Device is not an Owntone server." }, "error": { - "forbidden": "Unable to connect. Please check your forked-daapd network permissions.", + "forbidden": "Unable to connect. Please check your Owntone network permissions.", "unknown_error": "Unexpected error", - "websocket_not_enabled": "forked-daapd server websocket not enabled.", + "websocket_not_enabled": "Owntone server websocket not enabled.", "wrong_host_or_port": "Unable to connect. Please check host and port.", "wrong_password": "Incorrect password.", - "wrong_server_type": "The forked-daapd integration requires a forked-daapd server with version >= 27.0." + "wrong_server_type": "The Owntone integration requires an Owntone server with version >= 27.0." }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "API password (leave blank if no password)", "port": "API port" }, - "title": "Set up forked-daapd device" + "title": "Set up Owntone device" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "Seconds to pause before and after TTS", "tts_volume": "TTS volume (float in range [0,1])" }, - "description": "Set various options for the forked-daapd integration.", - "title": "Configure forked-daapd options" + "description": "Set various options for the Owntone integration.", + "title": "Configure Owntone options" } } } diff --git a/homeassistant/components/forked_daapd/translations/es.json b/homeassistant/components/forked_daapd/translations/es.json index 999ec0846d5e1ba7b5ab77fa05dc7bd7690d1578..9b5720e97953a0ad2bb85cb8a914cc92e45961c0 100644 --- a/homeassistant/components/forked_daapd/translations/es.json +++ b/homeassistant/components/forked_daapd/translations/es.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "El dispositivo ya est\u00e1 configurado", - "not_forked_daapd": "El dispositivo no es un servidor forked-daapd." + "not_forked_daapd": "El dispositivo no es un servidor Owntone." }, "error": { - "forbidden": "No se puede conectar. Por favor, comprueba los permisos de red de tu forked-daapd.", + "forbidden": "No se puede conectar. Por favor, comprueba tus permisos de red de Owntone.", "unknown_error": "Error inesperado", - "websocket_not_enabled": "Websocket del servidor forked-daapd no habilitado.", + "websocket_not_enabled": "Websocket del servidor Owntone no habilitado.", "wrong_host_or_port": "No se ha podido conectar. Por favor, comprueba host y puerto.", "wrong_password": "Contrase\u00f1a incorrecta.", - "wrong_server_type": "La integraci\u00f3n forked-daapd requiere un servidor forked-daapd con versi\u00f3n >= 27.0." + "wrong_server_type": "La integraci\u00f3n Owntone requiere un servidor Owntone con versi\u00f3n >= 27.0." }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "Contrase\u00f1a API (d\u00e9jala en blanco si no hay contrase\u00f1a)", "port": "Puerto API" }, - "title": "Configurar dispositivo forked-daapd" + "title": "Configurar dispositivo Owntone" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "Segundos para pausar antes y despu\u00e9s del TTS", "tts_volume": "Volumen TTS (decimal en el rango [0,1])" }, - "description": "Establece varias opciones para la integraci\u00f3n de forked-daapd.", - "title": "Configurar opciones de forked-daapd" + "description": "Configura varias opciones para la integraci\u00f3n Owntone.", + "title": "Configurar opciones de Owntone" } } } diff --git a/homeassistant/components/forked_daapd/translations/et.json b/homeassistant/components/forked_daapd/translations/et.json index a9413cf0cea421f27c841e0141868f6f8bd5e667..72e0ee77293e797b8a97182fa8d4ccd9512a1366 100644 --- a/homeassistant/components/forked_daapd/translations/et.json +++ b/homeassistant/components/forked_daapd/translations/et.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", - "not_forked_daapd": "Seade ei ole forked-daapd server." + "not_forked_daapd": "Seade ei oleOwntone server." }, "error": { - "forbidden": "Ei saa \u00fchendust. Kontrolli oma forked-daapd sidumise v\u00f5rgu\u00f5igusi.", + "forbidden": "Ei saa \u00fchendust. Kontrolli oma Owntone sidumise v\u00f5rgu\u00f5igusi.", "unknown_error": "Tundmatu viga", - "websocket_not_enabled": "forked- daapd serveri veebisoklit pole lubatud.", + "websocket_not_enabled": "Owntone serveri veebisoklit pole lubatud.", "wrong_host_or_port": "\u00dchendust ei saa luua. Palun kontrolli hosti ja porti.", "wrong_password": "Vale salas\u00f5na.", - "wrong_server_type": "Forked-daapd sidumine n\u00f5uab forked-daapd serveri versioon >= 27.0." + "wrong_server_type": "Owntone sidumine n\u00f5uab Owntone serveri versioon >= 27.0." }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "API salas\u00f5na (j\u00e4ta t\u00fchjaks kui salas\u00f5na puudub)", "port": "" }, - "title": "Seadista forked-daapd seade" + "title": "Seadista Owntone seade" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "Paus sekundites enne ja p\u00e4rast TTS teavitust", "tts_volume": "TTS helitugevus (ujukoma vahemikus [0-1])" }, - "description": "M\u00e4\u00e4rake forked-daapd-i sidumise erinevad valikud.", - "title": "Forked- daapd valikute seadistamine" + "description": "M\u00e4\u00e4ra Owntone sidumise erinevad valikud.", + "title": "Owntone valikute seadistamine" } } } diff --git a/homeassistant/components/forked_daapd/translations/hu.json b/homeassistant/components/forked_daapd/translations/hu.json index 51285d2f7d42623047e1e6b1c7bca44ff2940fc2..e10e4f19bd2543fe0779deb226400369e3f572d2 100644 --- a/homeassistant/components/forked_daapd/translations/hu.json +++ b/homeassistant/components/forked_daapd/translations/hu.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "not_forked_daapd": "Az eszk\u00f6z nem forked-daapd kiszolg\u00e1l\u00f3." + "not_forked_daapd": "Az eszk\u00f6z nem Owntone-kiszolg\u00e1l\u00f3." }, "error": { - "forbidden": "A csatlakoz\u00e1s sikertelen. K\u00e9rem, ellen\u0151rizze a forked-daapd h\u00e1l\u00f3zati enged\u00e9lyeket.", + "forbidden": "Nem siker\u00fclt csatlakozni. K\u00e9rj\u00fck, ellen\u0151rizze az Owntone h\u00e1l\u00f3zati enged\u00e9lyeit.", "unknown_error": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", - "websocket_not_enabled": "forked-daapd szerver websocket nincs enged\u00e9lyezve.", + "websocket_not_enabled": "Az Owntone server websocket nincs enged\u00e9lyezve.", "wrong_host_or_port": "A csatlakoz\u00e1s sikertelen. K\u00e9rem, ellen\u0151rizze a c\u00edmet \u00e9s a portot.", "wrong_password": "Helytelen jelsz\u00f3.", - "wrong_server_type": "A forked-daapd integr\u00e1ci\u00f3hoz forked-daapd szerver sz\u00fcks\u00e9ges, amelynek verzi\u00f3ja legal\u00e1bb 27.0." + "wrong_server_type": "Az Owntone integr\u00e1ci\u00f3hoz egy Owntone szerverre van sz\u00fcks\u00e9g, amelynek verzi\u00f3ja legal\u00e1bb 27.0." }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "API jelsz\u00f3 (hagyja \u00fcresen, ha nincs jelsz\u00f3)", "port": "API port" }, - "title": "\u00c1ll\u00edtsa be a forked-daapd eszk\u00f6zt" + "title": "Owntone eszk\u00f6z be\u00e1ll\u00edt\u00e1sa" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "M\u00e1sodpercek a TTS el\u0151tti \u00e9s ut\u00e1ni sz\u00fcnethez", "tts_volume": "TTS hanger\u0151 (lebeg\u0151 a [0,1] tartom\u00e1nyban)" }, - "description": "A forked-daapd integr\u00e1ci\u00f3 be\u00e1ll\u00edt\u00e1sai.", - "title": "A forked-daapd be\u00e1ll\u00edt\u00e1sainak konfigur\u00e1l\u00e1sa" + "description": "Az Owntone integr\u00e1ci\u00f3 k\u00fcl\u00f6nb\u00f6z\u0151 be\u00e1ll\u00edt\u00e1sai.", + "title": "Owntone be\u00e1ll\u00edt\u00e1sok konfigur\u00e1l\u00e1sa" } } } diff --git a/homeassistant/components/forked_daapd/translations/id.json b/homeassistant/components/forked_daapd/translations/id.json index f57a8fb856695b4c4a00317ac11865a8bdd8d2bd..1563443699cf4b9b5339b93c1a14603af5a48093 100644 --- a/homeassistant/components/forked_daapd/translations/id.json +++ b/homeassistant/components/forked_daapd/translations/id.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "Perangkat sudah dikonfigurasi", - "not_forked_daapd": "Perangkat bukan server forked-daapd." + "not_forked_daapd": "Perangkat bukan server Owntone." }, "error": { - "forbidden": "Tidak dapat terhubung. Periksa izin jaringan forked-daapd Anda.", + "forbidden": "Tidak dapat terhubung. Periksa izin jaringan Owntone Anda.", "unknown_error": "Kesalahan yang tidak diharapkan", - "websocket_not_enabled": "Websocket server forked-daapd tidak diaktifkan.", + "websocket_not_enabled": "Websocket server Owntone tidak diaktifkan.", "wrong_host_or_port": "Tidak dapat terhubung. Periksa nilai host dan port.", "wrong_password": "Kata sandi salah.", - "wrong_server_type": "Integrasi forked-daapd membutuhkan server forked-daapd dengan versi >= 27.0." + "wrong_server_type": "Integrasi Owntone membutuhkan server forked-daapd dengan versi >= 27.0." }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "Kata sandi API (kosongkan jika tidak ada kata sandi)", "port": "Port API" }, - "title": "Siapkan perangkat forked-daapd" + "title": "Siapkan perangkat Owntone" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "Tenggang waktu dalam detik sebelum dan setelah TTS", "tts_volume": "Volume TTS (bilangan float dalam rentang [0,1])" }, - "description": "Tentukan berbagai opsi untuk integrasi forked-daapd.", - "title": "Konfigurasikan opsi forked-daapd" + "description": "Tentukan berbagai opsi untuk integrasi Owntone.", + "title": "Konfigurasikan opsi Owntone" } } } diff --git a/homeassistant/components/forked_daapd/translations/it.json b/homeassistant/components/forked_daapd/translations/it.json index a5a1d0922815c6dd646c7ec7eee0aa02b48569bd..107d6cb8eef47f7d7fb557b04f989593a98d9d28 100644 --- a/homeassistant/components/forked_daapd/translations/it.json +++ b/homeassistant/components/forked_daapd/translations/it.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", - "not_forked_daapd": "Il dispositivo non \u00e8 un server forked-daapd." + "not_forked_daapd": "Il dispositivo non \u00e8 un server Owntone." }, "error": { - "forbidden": "Impossibile connettersi. Controlla i permessi di rete forked-daapd.", + "forbidden": "Impossibile connetersi. Controlla le autorizzazioni di rete di Owntone.", "unknown_error": "Errore imprevisto", - "websocket_not_enabled": "websocket del server forked-daapd non abilitato.", + "websocket_not_enabled": "Websocket del server Owntone non abilitato.", "wrong_host_or_port": "Impossibile connettersi. Controlla host e porta.", "wrong_password": "Password errata", - "wrong_server_type": "L'integrazione forked-daapd richiede un server forked-daapd con versione >= 27.0." + "wrong_server_type": "L'integrazione Owntone richiede un server Owntone con versione >= 27.0." }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "Password API (lascia vuota se non c'\u00e8 password)", "port": "Porta API" }, - "title": "Configura il dispositivo forked-daapd" + "title": "Configura dispositivo Owntone" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "Secondi di pausa prima e dopo il TTS", "tts_volume": "Volume TTS (variabile nell'intervallo [0,1])" }, - "description": "Imposta le varie opzioni per l'integrazione forked-daapd.", - "title": "Configura le opzioni forked-daapd" + "description": "Imposta varie opzioni per l'integrazione Owntone.", + "title": "Configurare le opzioni Owntone" } } } diff --git a/homeassistant/components/forked_daapd/translations/nb.json b/homeassistant/components/forked_daapd/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..4518f3cd8cb8990549d040876ed38f1c26b6c848 --- /dev/null +++ b/homeassistant/components/forked_daapd/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown_error": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forked_daapd/translations/no.json b/homeassistant/components/forked_daapd/translations/no.json index 4461101cbc2353ab24b1f9c48463be377b056b38..05093ac08c70e0ec6715ef75989dcdc557b1417f 100644 --- a/homeassistant/components/forked_daapd/translations/no.json +++ b/homeassistant/components/forked_daapd/translations/no.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "not_forked_daapd": "Enheten er ikke en forked-daapd-server." + "not_forked_daapd": "Enheten er ikke en Owntone-server." }, "error": { - "forbidden": "Kan ikke koble til, vennligst sjekk dine forked-daapd nettverkstillatelser", + "forbidden": "Kan ikke koble til. Sjekk dine Owntone-nettverkstillatelser.", "unknown_error": "Uventet feil", - "websocket_not_enabled": "websocket for forked-daapd server ikke aktivert.", + "websocket_not_enabled": "Owntone server websocket ikke aktivert.", "wrong_host_or_port": "Kan ikke koble til. Vennligst sjekk vert og port.", "wrong_password": "Feil passord.", - "wrong_server_type": "Forked-daapd integrasjon krever en gaffel-daapd server med versjon \"= 27.0." + "wrong_server_type": "Owntone-integrasjonen krever en Owntone-server med versjon > = 27.0." }, "flow_title": "{name} ( {host} )", "step": { @@ -21,7 +21,7 @@ "password": "API-passord (la st\u00e5 tomt hvis ingen passord)", "port": "" }, - "title": "Konfigurere forked-daapd-enhet" + "title": "Sett opp Owntone-enhet" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "Sekunder for \u00e5 sette pause f\u00f8r og etter TTS", "tts_volume": "TTS-volum (flyter i omr\u00e5det [0,1])" }, - "description": "Angi ulike alternativer for forked-daapd integrasjon.", - "title": "Konfigurer alternativer for forked-daapd" + "description": "Angi ulike alternativer for Owntone-integrasjonen.", + "title": "Konfigurer Owntone-alternativer" } } } diff --git a/homeassistant/components/forked_daapd/translations/pl.json b/homeassistant/components/forked_daapd/translations/pl.json index bb20159bd1a756602c8ec47faa0dcfb78b3ced34..b560fb1ef900619f141fc136e5232dc8f60b1a40 100644 --- a/homeassistant/components/forked_daapd/translations/pl.json +++ b/homeassistant/components/forked_daapd/translations/pl.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", - "not_forked_daapd": "Urz\u0105dzenie nie jest serwerem forked-daapd" + "not_forked_daapd": "Urz\u0105dzenie nie jest serwerem Owntone" }, "error": { - "forbidden": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia. Sprawd\u017a uprawnienia sieciowe forked-daapd.", + "forbidden": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia. Sprawd\u017a uprawnienia sieciowe Owntone.", "unknown_error": "Nieoczekiwany b\u0142\u0105d", - "websocket_not_enabled": "Websocket serwera forked-daapd nie jest w\u0142\u0105czony", + "websocket_not_enabled": "Websocket serwera Owntone nie jest w\u0142\u0105czony", "wrong_host_or_port": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia. Sprawd\u017a adres hosta i port.", "wrong_password": "Nieprawid\u0142owe has\u0142o", - "wrong_server_type": "Integracja forked-daapd wymaga serwera forked-daapd w wersji >= 27.0" + "wrong_server_type": "Integracja Owntone wymaga serwera Owntone w wersji >= 27.0" }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "Has\u0142o API (pozostaw puste, je\u015bli nie ma has\u0142a)", "port": "Port API" }, - "title": "Konfiguracja urz\u0105dzenia forked-daapd" + "title": "Konfiguracja urz\u0105dzenia Owntone" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "Przerwa przed i po TTS (w sekundach)", "tts_volume": "G\u0142o\u015bno\u015b\u0107 TTS (w zakresie od 0 do 1, np. 0.5 = 50%)" }, - "description": "Ustawianie r\u00f3\u017cnych opcji dla integracji forked-daapd", - "title": "Konfiguracja opcji forked-daapd" + "description": "Ustawianie r\u00f3\u017cnych opcji dla integracji Owntone", + "title": "Konfiguracja opcji Owntone" } } } diff --git a/homeassistant/components/forked_daapd/translations/pt-BR.json b/homeassistant/components/forked_daapd/translations/pt-BR.json index 1b768604befe4b86da3fd3831af716ff92686510..adf57ed7ba1694e0f96a2f9c604a8dfe80cfdf62 100644 --- a/homeassistant/components/forked_daapd/translations/pt-BR.json +++ b/homeassistant/components/forked_daapd/translations/pt-BR.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", - "not_forked_daapd": "O dispositivo n\u00e3o \u00e9 um servidor forked-daapd." + "not_forked_daapd": "O dispositivo n\u00e3o \u00e9 um servidor Owntone." }, "error": { - "forbidden": "Incapaz de conectar. Verifique suas permiss\u00f5es de rede forked-daapd.", + "forbidden": "Incapaz de conectar. Verifique suas permiss\u00f5es de rede Owntone.", "unknown_error": "Erro inesperado", - "websocket_not_enabled": "websocket do servidor forked-daapd n\u00e3o ativado.", + "websocket_not_enabled": "Websocket do servidor Owntone n\u00e3o ativado.", "wrong_host_or_port": "N\u00e3o foi poss\u00edvel conectar. Por favor, verifique o endere\u00e7o e a porta.", "wrong_password": "Senha incorreta.", - "wrong_server_type": "A integra\u00e7\u00e3o forked-daapd requer um servidor forked-daapd com vers\u00e3o >= 27.0." + "wrong_server_type": "A integra\u00e7\u00e3o Owntone requer um servidor Owntone com vers\u00e3o > = 27.0." }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "Senha da API (deixe em branco se n\u00e3o houver senha)", "port": "Porta API" }, - "title": "Configurar dispositivo forked-daapd" + "title": "Configurar dispositivo Owntone" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "Segundos para pausar antes e depois do TTS", "tts_volume": "Volume TTS (flutua\u00e7\u00e3o na faixa [0,1])" }, - "description": "Defina v\u00e1rias op\u00e7\u00f5es para a integra\u00e7\u00e3o forked-daapd.", - "title": "Configurar op\u00e7\u00f5es forked-daapd" + "description": "Defina v\u00e1rias op\u00e7\u00f5es para a integra\u00e7\u00e3o do Owntone.", + "title": "Configurar op\u00e7\u00f5es do Owntone" } } } diff --git a/homeassistant/components/forked_daapd/translations/ru.json b/homeassistant/components/forked_daapd/translations/ru.json index 3850c895353a1e66ae53ee99feccb1709ec6ea4f..00ab1d3f635f250205bdf9645c10bcf23dfbd332 100644 --- a/homeassistant/components/forked_daapd/translations/ru.json +++ b/homeassistant/components/forked_daapd/translations/ru.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", - "not_forked_daapd": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0441\u0435\u0440\u0432\u0435\u0440 forked-daapd." + "not_forked_daapd": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0441\u0435\u0440\u0432\u0435\u0440 Owntone." }, "error": { - "forbidden": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0441\u0435\u0442\u0435\u0432\u044b\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u044f forked-daapd.", + "forbidden": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0441\u0435\u0442\u0435\u0432\u044b\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u044f Owntone.", "unknown_error": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430.", - "websocket_not_enabled": "\u0412\u0435\u0431-\u0441\u043e\u043a\u0435\u0442 forked-daapd \u043d\u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d.", + "websocket_not_enabled": "\u0412\u0435\u0431-\u0441\u043e\u043a\u0435\u0442 Owntone \u043d\u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d.", "wrong_host_or_port": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430.", "wrong_password": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c.", - "wrong_server_type": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0441\u0435\u0440\u0432\u0435\u0440 forked-daapd \u0432\u0435\u0440\u0441\u0438\u0438 27.0 \u0438\u043b\u0438 \u0432\u044b\u0448\u0435." + "wrong_server_type": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0441\u0435\u0440\u0432\u0435\u0440 Owntone \u0432\u0435\u0440\u0441\u0438\u0438 27.0 \u0438\u043b\u0438 \u0432\u044b\u0448\u0435." }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c API (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c, \u0435\u0441\u043b\u0438 \u043d\u0435\u0442 \u043f\u0430\u0440\u043e\u043b\u044f)", "port": "\u041f\u043e\u0440\u0442 API" }, - "title": "forked-daapd" + "title": "Owntone" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "\u0412\u0440\u0435\u043c\u044f \u043f\u0430\u0443\u0437\u044b \u0434\u043e \u0438 \u043f\u043e\u0441\u043b\u0435 TTS (\u0441\u0435\u043a.)", "tts_volume": "\u0413\u0440\u043e\u043c\u043a\u043e\u0441\u0442\u044c TTS (\u0447\u0438\u0441\u043b\u043e \u0432 \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d\u0435 \u043e\u0442 0 \u0434\u043e 1)" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 forked-daapd.", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 forked-daapd" + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 Owntone.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Owntone" } } } diff --git a/homeassistant/components/forked_daapd/translations/tr.json b/homeassistant/components/forked_daapd/translations/tr.json index 6c838e930556961870b1e93436027a80963b02cf..c9345c00ec57631334627ab5b284380ed3d05844 100644 --- a/homeassistant/components/forked_daapd/translations/tr.json +++ b/homeassistant/components/forked_daapd/translations/tr.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "not_forked_daapd": "Cihaz, forked-daapd sunucusu de\u011fil." + "not_forked_daapd": "Cihaz bir Owntone sunucusu de\u011fil." }, "error": { - "forbidden": "Ba\u011flan\u0131lam\u0131yor. L\u00fctfen forked-daapd a\u011f izinlerinizi kontrol edin.", + "forbidden": "Ba\u011flan\u0131lam\u0131yor. L\u00fctfen Owntone a\u011f izinlerinizi kontrol edin.", "unknown_error": "Beklenmeyen hata", - "websocket_not_enabled": "forked-daapd sunucu websocket etkin de\u011fil.", + "websocket_not_enabled": "Owntone sunucusu websocket etkinle\u015ftirilmedi.", "wrong_host_or_port": "Ba\u011flan\u0131lam\u0131yor. L\u00fctfen ana bilgisayar\u0131 ve ba\u011flant\u0131 noktas\u0131n\u0131 kontrol edin.", "wrong_password": "Yanl\u0131\u015f parola.", - "wrong_server_type": "> = 27.0 s\u00fcr\u00fcm\u00fcne sahip bir forked-daapd sunucusu gerektirir." + "wrong_server_type": "Owntone entegrasyonu, > = 27.0 s\u00fcr\u00fcm\u00fcne sahip bir Owntone sunucusu gerektirir." }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "API parolas\u0131 (parola yoksa bo\u015f b\u0131rak\u0131n)", "port": "API Port" }, - "title": "Forked-daapd cihaz\u0131n\u0131 kurun" + "title": "Owntone cihaz\u0131n\u0131 kurun" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "TTS'den \u00f6nce ve sonra duraklatmak i\u00e7in saniyeler", "tts_volume": "TTS ses seviyesi (aral\u0131k [0,1])" }, - "description": "Forked-daapd entegrasyonu i\u00e7in \u00e7e\u015fitli se\u00e7enekleri ayarlay\u0131n.", - "title": "Forked-daapd se\u00e7eneklerini yap\u0131land\u0131r\u0131n" + "description": "Owntone entegrasyonu i\u00e7in \u00e7e\u015fitli se\u00e7enekleri ayarlay\u0131n.", + "title": "Owntone se\u00e7eneklerini yap\u0131land\u0131rma" } } } diff --git a/homeassistant/components/forked_daapd/translations/zh-Hant.json b/homeassistant/components/forked_daapd/translations/zh-Hant.json index 9d91fb930331734446d97f8cf521885948d786fd..51963bed10ff0726a6f845b58c81b26edf04f30c 100644 --- a/homeassistant/components/forked_daapd/translations/zh-Hant.json +++ b/homeassistant/components/forked_daapd/translations/zh-Hant.json @@ -2,15 +2,15 @@ "config": { "abort": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "not_forked_daapd": "\u88dd\u7f6e\u4e26\u975e forked-daapd \u4f3a\u670d\u5668\u3002" + "not_forked_daapd": "\u88dd\u7f6e\u4e26\u975e Owntone \u4f3a\u670d\u5668\u3002" }, "error": { - "forbidden": "\u7121\u6cd5\u9023\u7dda\uff0c\u8acb\u78ba\u8a8d forked-daapd \u7db2\u8def\u6b0a\u9650\u3002", + "forbidden": "\u7121\u6cd5\u9023\u7dda\uff0c\u8acb\u78ba\u8a8d Owntone \u7db2\u8def\u6b0a\u9650\u3002", "unknown_error": "\u672a\u9810\u671f\u932f\u8aa4", - "websocket_not_enabled": "forked-daapd \u4f3a\u670d\u5668 websocket \u672a\u958b\u555f\u3002", + "websocket_not_enabled": "Owntone \u4f3a\u670d\u5668 websocket \u672a\u958b\u555f\u3002", "wrong_host_or_port": "\u7121\u6cd5\u9023\u7dda\uff0c\u8acb\u78ba\u8a8d\u4e3b\u6a5f\u8207\u901a\u8a0a\u57e0\u3002", "wrong_password": "\u5bc6\u78bc\u932f\u8aa4\u3002", - "wrong_server_type": "forked-daapd \u6574\u5408\u9700\u8981\u7248\u6b21 >= 27.0 \u7248\u4e4b forked-daapd \u4f3a\u670d\u5668\u3002" + "wrong_server_type": "Owntone \u6574\u5408\u9700\u8981\u7248\u6b21 >= 27.0 \u7248\u4e4b Owntone \u4f3a\u670d\u5668\u3002" }, "flow_title": "{name} ({host})", "step": { @@ -21,7 +21,7 @@ "password": "API \u5bc6\u78bc\uff08\u5047\u5982\u7121\u5bc6\u78bc\uff0c\u8acb\u7559\u7a7a\uff09", "port": "API \u901a\u8a0a\u57e0" }, - "title": "\u8a2d\u5b9a forked-daapd \u88dd\u7f6e" + "title": "\u8a2d\u5b9a Owntone \u88dd\u7f6e" } } }, @@ -34,8 +34,8 @@ "tts_pause_time": "\u65bc TTS \u524d\u5f8c\u66ab\u505c\u79d2\u6578", "tts_volume": "TTS \u97f3\u91cf\uff08\u6d6e\u52d5\u7bc4\u570d [0,1]\uff09" }, - "description": "\u8a2d\u5b9a forked-daapd \u6574\u5408\u9078\u9805\u3002", - "title": "forked-daapd \u8a2d\u5b9a\u9078\u9805" + "description": "\u8a2d\u5b9a Owntone \u6574\u5408\u9078\u9805\u3002", + "title": "Owntone \u8a2d\u5b9a\u9078\u9805" } } } diff --git a/homeassistant/components/foscam/translations/nb.json b/homeassistant/components/foscam/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/foscam/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/button.py b/homeassistant/components/freebox/button.py index b3313bba9ddce173fa1b2b6700faa1c993836c82..69fa10ff2683516f48c32d7f2da9d58c109f24d3 100644 --- a/homeassistant/components/freebox/button.py +++ b/homeassistant/components/freebox/button.py @@ -11,7 +11,7 @@ from homeassistant.components.button import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -37,8 +37,15 @@ BUTTON_DESCRIPTIONS: tuple[FreeboxButtonEntityDescription, ...] = ( key="reboot", name="Reboot Freebox", device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, async_press=lambda router: router.reboot(), ), + FreeboxButtonEntityDescription( + key="mark_calls_as_read", + name="Mark calls as read", + entity_category=EntityCategory.DIAGNOSTIC, + async_press=lambda router: router.call.mark_calls_log_as_read(), + ), ) diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py index bd71588aea0af307c15e91c0adae0ab0c7034e89..0b48d08170ae68788393254f68c2e80804c4cc24 100644 --- a/homeassistant/components/freebox/device_tracker.py +++ b/homeassistant/components/freebox/device_tracker.py @@ -48,8 +48,7 @@ def add_entities( new_tracked.append(FreeboxDevice(router, device)) tracked.add(mac) - if new_tracked: - async_add_entities(new_tracked, True) + async_add_entities(new_tracked, True) class FreeboxDevice(ScannerEntity): diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 846bff5f8ce43ac33d1a824523589ce38ad99cad..44d9b47557caa655e398c9e9a4dffeb889e10b2a 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -3,7 +3,7 @@ "name": "Freebox", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/freebox", - "requirements": ["freebox-api==0.0.10"], + "requirements": ["freebox-api==1.0.1"], "zeroconf": ["_fbx-api._tcp.local."], "codeowners": ["@hacf-fr", "@Quentame"], "iot_class": "local_polling", diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index ce4d03aae7a5c4bdd5c07da114aeb1576f5727da..0fb0f10a27deac1817c7384557ea7222583f4e90 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any from freebox_api import Freepybox +from freebox_api.api.call import Call from freebox_api.api.wifi import Wifi from freebox_api.exceptions import NotOpenError @@ -186,6 +187,11 @@ class FreeboxRouter: """Return sensors.""" return {**self.sensors_temperature, **self.sensors_connection} + @property + def call(self) -> Call: + """Return the call.""" + return self._api.call + @property def wifi(self) -> Wifi: """Return the wifi.""" diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index ab5a1160b711463006544a97c59883994a26551e..9bef539bfd8ba95bff3ab06595fc68ad42a6c7c9 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -6,10 +6,10 @@ from typing import Any from freebox_api.exceptions import InsufficientPermissionsError -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 import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -18,49 +18,43 @@ from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) +SWITCH_DESCRIPTIONS = [ + SwitchEntityDescription( + key="wifi", + name="Freebox WiFi", + entity_category=EntityCategory.CONFIG, + ) +] + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the switch.""" router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] - async_add_entities([FreeboxWifiSwitch(router)], True) + entities = [ + FreeboxSwitch(router, entity_description) + for entity_description in SWITCH_DESCRIPTIONS + ] + async_add_entities(entities, True) -class FreeboxWifiSwitch(SwitchEntity): - """Representation of a freebox wifi switch.""" +class FreeboxSwitch(SwitchEntity): + """Representation of a freebox switch.""" - def __init__(self, router: FreeboxRouter) -> None: - """Initialize the Wifi switch.""" - self._name = "Freebox WiFi" - self._state: bool | None = None + def __init__( + self, router: FreeboxRouter, entity_description: SwitchEntityDescription + ) -> None: + """Initialize the switch.""" + self.entity_description = entity_description self._router = router - self._unique_id = f"{self._router.mac} {self._name}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the switch.""" - return self._name - - @property - def is_on(self) -> bool | None: - """Return true if device is on.""" - return self._state - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return self._router.device_info + self._attr_device_info = self._router.device_info + self._attr_unique_id = f"{self._router.mac} {self.entity_description.name}" async def _async_set_state(self, enabled: bool): """Turn the switch on or off.""" - wifi_config = {"enabled": enabled} try: - await self._router.wifi.set_global_config(wifi_config) + await self._router.wifi.set_global_config({"enabled": enabled}) except InsufficientPermissionsError: _LOGGER.warning( "Home Assistant does not have permissions to modify the Freebox settings. Please refer to documentation" @@ -77,5 +71,4 @@ class FreeboxWifiSwitch(SwitchEntity): async def async_update(self) -> None: """Get the state and update it.""" datas = await self._router.wifi.get_global_config() - active = datas["enabled"] - self._state = bool(active) + self._attr_is_on = bool(datas["enabled"]) diff --git a/homeassistant/components/freebox/translations/nb.json b/homeassistant/components/freebox/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/freebox/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index 9591fec156b884f601bc6437376511bb969900ae..998eab2ede73f03a212d1a37b840414d658e23fd 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -65,8 +65,7 @@ def _async_add_entities( new_tracked.append(FritzBoxTracker(avm_wrapper, device)) data_fritz.tracked[avm_wrapper.unique_id].add(mac) - if new_tracked: - async_add_entities(new_tracked) + async_add_entities(new_tracked) class FritzBoxTracker(FritzDeviceBase, ScannerEntity): diff --git a/homeassistant/components/fritz/translations/bg.json b/homeassistant/components/fritz/translations/bg.json index e9162b8b35ba39365f31cb38e18ec22f692b4a0f..7341a275d4674598111ba2799db59b0a125643b4 100644 --- a/homeassistant/components/fritz/translations/bg.json +++ b/homeassistant/components/fritz/translations/bg.json @@ -1,14 +1,28 @@ { "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", "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": { + "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", "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": "{name}", "step": { + "confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + }, + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/fritz/translations/no.json b/homeassistant/components/fritz/translations/no.json index 5ed9763667560263630ab2d1c1fe8887276ac092..7e773022475bd621e31924c9e242de7650b9471a 100644 --- a/homeassistant/components/fritz/translations/no.json +++ b/homeassistant/components/fritz/translations/no.json @@ -4,7 +4,7 @@ "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", "ignore_ip6_link_local": "IPv6-lenkens lokale adresse st\u00f8ttes ikke.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "already_configured": "Enheten er allerede konfigurert", diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index e01fb76ec117c3afed00d1cc6a65e06ecba8d7f0..0f7037843d801cb8fd6cce7a2fe312d0e454e132 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -7,7 +7,7 @@ from requests.exceptions import HTTPError from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -15,7 +15,6 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import color from . import FritzBoxEntity from .const import ( @@ -67,17 +66,13 @@ class FritzboxLight(FritzBoxEntity, LightEntity): coordinator: FritzboxDataUpdateCoordinator, ain: str, supported_colors: dict, - supported_color_temps: list[str], + supported_color_temps: list[int], ) -> None: """Initialize the FritzboxLight entity.""" super().__init__(coordinator, ain, None) - max_kelvin = int(max(supported_color_temps)) - min_kelvin = int(min(supported_color_temps)) - - # max kelvin is min mireds and min kelvin is max mireds - self._attr_min_mireds = color.color_temperature_kelvin_to_mired(max_kelvin) - self._attr_max_mireds = color.color_temperature_kelvin_to_mired(min_kelvin) + self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) + self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup @@ -112,13 +107,12 @@ class FritzboxLight(FritzBoxEntity, LightEntity): return (hue, float(saturation) * 100.0 / 255.0) @property - def color_temp(self) -> int | None: + def color_temp_kelvin(self) -> int | None: """Return the CT color value.""" if self.device.color_mode != COLOR_TEMP_MODE: return None - kelvin = self.device.color_temp - return color.color_temperature_kelvin_to_mired(kelvin) + return self.device.color_temp # type: ignore [no-any-return] @property def color_mode(self) -> ColorMode: @@ -166,9 +160,10 @@ class FritzboxLight(FritzBoxEntity, LightEntity): self.device.set_color, (hue, saturation) ) - if kwargs.get(ATTR_COLOR_TEMP) is not None: - kelvin = color.color_temperature_kelvin_to_mired(kwargs[ATTR_COLOR_TEMP]) - await self.hass.async_add_executor_job(self.device.set_color_temp, kelvin) + if kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None: + await self.hass.async_add_executor_job( + self.device.set_color_temp, kwargs[ATTR_COLOR_TEMP_KELVIN] + ) await self.hass.async_add_executor_job(self.device.set_state_on) await self.coordinator.async_refresh() diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 7253fdcf36edcb14dad9738c018c60f260b68586..ab341fb1520af87b2e96944a0c89cf66a0b07cc4 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -76,7 +76,11 @@ def suitable_temperature(device: FritzhomeDevice) -> bool: def value_electric_current(device: FritzhomeDevice) -> float: """Return native value for electric current sensor.""" - if isinstance(device.power, int) and isinstance(device.voltage, int): + if ( + isinstance(device.power, int) + and isinstance(device.voltage, int) + and device.voltage > 0 + ): return round(device.power / device.voltage, 3) return 0.0 diff --git a/homeassistant/components/fritzbox/translations/no.json b/homeassistant/components/fritzbox/translations/no.json index 98174053a615cebe471478e189f5fe0505aac395..e6d464b3f3755648deba0ff4254c0a8fbca86224 100644 --- a/homeassistant/components/fritzbox/translations/no.json +++ b/homeassistant/components/fritzbox/translations/no.json @@ -6,7 +6,7 @@ "ignore_ip6_link_local": "IPv6-lenkens lokale adresse st\u00f8ttes ikke.", "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", "not_supported": "Tilkoblet AVM FRITZ! Box, men den klarer ikke \u00e5 kontrollere Smart Home-enheter.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning" diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index 9dba2143ce14a8a2bbf03373f57ea7cf0608b999..79ca743bbeb618062aa3d8dd58967fb9253d1af0 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -1,6 +1,7 @@ { "domain": "fritzbox_callmonitor", "name": "AVM FRITZ!Box Call Monitor", + "integration_type": "device", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor", "requirements": ["fritzconnection==1.10.3"], diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index c1090dab1b1059da3b8de8b2b325d51d6b133f6f..08779b9b67373e8cddffaeefd2679021bb0707ac 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -104,8 +104,7 @@ class FroniusCoordinatorBase( continue new_entities.append(entity_constructor(self, key, solar_net_id)) self.unregistered_keys[solar_net_id].remove(key) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) _add_entities_for_unregistered_keys() self.solar_net.cleanup_callbacks.append( diff --git a/homeassistant/components/fronius/translations/nb.json b/homeassistant/components/fronius/translations/nb.json index 89900954d12aea29ab4baa9442ceb24dcd495cc9..96b2a2b4cc25b0ee492b3f1972bdfcabc142d44e 100644 --- a/homeassistant/components/fronius/translations/nb.json +++ b/homeassistant/components/fronius/translations/nb.json @@ -2,6 +2,9 @@ "config": { "abort": { "invalid_host": "Ugyldig vertsnavn eller IP-adresse" + }, + "error": { + "unknown": "Uventet feil" } } } \ No newline at end of file diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 40989f41f199099a6c2ad4138b74ab9b6222d923..f5fe37c1819fd805d695006590b9a5c1149cca82 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -614,7 +614,7 @@ class ManifestJSONView(HomeAssistantView): @callback @websocket_api.websocket_command({"type": "get_panels"}) def websocket_get_panels( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get panels command.""" user_is_admin = connection.user.is_admin @@ -630,7 +630,7 @@ def websocket_get_panels( @callback @websocket_api.websocket_command({"type": "frontend/get_themes"}) def websocket_get_themes( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get themes command.""" if hass.config.safe_mode: @@ -673,7 +673,7 @@ def websocket_get_themes( ) @websocket_api.async_response async def websocket_get_translations( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get translations command.""" resources = await async_get_translations( @@ -691,7 +691,7 @@ async def websocket_get_translations( @websocket_api.websocket_command({"type": "frontend/get_version"}) @websocket_api.async_response async def websocket_get_version( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get version command.""" integration = await async_get_integration(hass, "frontend") diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 98b978964a6a530607eb4f0747df6d763272fbb0..f4f46a1f89b304e29e9a010ae06c55ffb5d20fc1 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==20221010.0"], + "requirements": ["home-assistant-frontend==20221102.1"], "dependencies": [ "api", "auth", diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index bfda566de5558391102b6ec288ad49a16ac4b29e..018850f596056b94c0c9c6eae9b0af677f299d07 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -61,7 +61,7 @@ def with_store(orig_func: Callable) -> Callable: async def websocket_set_user_data( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], store: Store, data: dict[str, Any], ) -> None: @@ -82,7 +82,7 @@ async def websocket_set_user_data( async def websocket_get_user_data( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], store: Store, data: dict[str, Any], ) -> None: diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py index 86ab769e0ecf3401c64436b50668898ced3e0c49..e417d7c0bcb0f99e6fa6115df3b897538e266abc 100644 --- a/homeassistant/components/fully_kiosk/__init__.py +++ b/homeassistant/components/fully_kiosk/__init__.py @@ -5,6 +5,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import FullyKioskDataUpdateCoordinator +from .services import async_setup_services PLATFORMS = [ Platform.BINARY_SENSOR, @@ -26,6 +27,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await async_setup_services(hass) + return True diff --git a/homeassistant/components/fully_kiosk/const.py b/homeassistant/components/fully_kiosk/const.py index 4af7628ed635a51fd3da7791fd641bb91267fed8..b4fe90e01eba5a319b7e5e6516a9b563cac463df 100644 --- a/homeassistant/components/fully_kiosk/const.py +++ b/homeassistant/components/fully_kiosk/const.py @@ -22,3 +22,9 @@ MEDIA_SUPPORT_FULLYKIOSK = ( | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.BROWSE_MEDIA ) + +SERVICE_LOAD_URL = "load_url" +SERVICE_START_APPLICATION = "start_application" + +ATTR_URL = "url" +ATTR_APPLICATION = "application" diff --git a/homeassistant/components/fully_kiosk/manifest.json b/homeassistant/components/fully_kiosk/manifest.json index 8918ce28062073d80c92049a81a81a4f28a0b139..5601cb074f0295128e1c23893e632879cbe612cc 100644 --- a/homeassistant/components/fully_kiosk/manifest.json +++ b/homeassistant/components/fully_kiosk/manifest.json @@ -4,7 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fullykiosk", "requirements": ["python-fullykiosk==0.0.11"], - "dependencies": [], "codeowners": ["@cgarwood"], "iot_class": "local_polling", "dhcp": [{ "registered_devices": true }] diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py new file mode 100644 index 0000000000000000000000000000000000000000..2283904dfa9247e3fda71eb544fdf30f5cca5e95 --- /dev/null +++ b/homeassistant/components/fully_kiosk/services.py @@ -0,0 +1,73 @@ +"""Services for the Fully Kiosk Browser integration.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr + +from .const import ( + ATTR_APPLICATION, + ATTR_URL, + DOMAIN, + SERVICE_LOAD_URL, + SERVICE_START_APPLICATION, +) + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Fully Kiosk Browser integration.""" + + async def async_load_url(call: ServiceCall) -> None: + """Load a URL on the Fully Kiosk Browser.""" + registry = dr.async_get(hass) + for target in call.data[ATTR_DEVICE_ID]: + + device = registry.async_get(target) + if device: + coordinator = hass.data[DOMAIN][list(device.config_entries)[0]] + await coordinator.fully.loadUrl(call.data[ATTR_URL]) + + async def async_start_app(call: ServiceCall) -> None: + """Start an app on the device.""" + registry = dr.async_get(hass) + for target in call.data[ATTR_DEVICE_ID]: + + device = registry.async_get(target) + if device: + coordinator = hass.data[DOMAIN][list(device.config_entries)[0]] + await coordinator.fully.startApplication(call.data[ATTR_APPLICATION]) + + hass.services.async_register( + DOMAIN, + SERVICE_LOAD_URL, + async_load_url, + schema=vol.Schema( + vol.All( + { + vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required( + ATTR_URL, + ): cv.string, + }, + ) + ), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_START_APPLICATION, + async_start_app, + schema=vol.Schema( + vol.All( + { + vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required( + ATTR_APPLICATION, + ): cv.string, + }, + ) + ), + ) diff --git a/homeassistant/components/fully_kiosk/services.yaml b/homeassistant/components/fully_kiosk/services.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b8ea6b371d7d7fa167af997adce123a8bab4323e --- /dev/null +++ b/homeassistant/components/fully_kiosk/services.yaml @@ -0,0 +1,29 @@ +load_url: + name: Load URL + description: Load a URL on Fully Kiosk Browser + target: + device: + integration: fully_kiosk + fields: + url: + name: URL + description: URL to load. + example: "https://home-assistant.io" + required: true + selector: + text: + +start_application: + name: Start Application + description: Start an application on the device running Fully Kiosk Browser. + target: + device: + integration: fully_kiosk + fields: + url: + name: Application + description: Package name of the application to start. + example: "de.ozerov.fully" + required: true + selector: + text: diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index 05b9e06796246a8600da4f5e1ddedb02ecc7a468..873ebc661fb658e6745f75dea819e6ca43c1cd1b 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -10,7 +10,6 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py index 581700c87d655a9a0899e10e163ab0b9654ab711..28407a66da1a033e9e72a555194ea89ae6502e88 100644 --- a/homeassistant/components/fully_kiosk/switch.py +++ b/homeassistant/components/fully_kiosk/switch.py @@ -109,9 +109,9 @@ class FullySwitchEntity(FullyKioskEntity, SwitchEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" - if self.entity_description.is_on_fn(self.coordinator.data) is not None: - return bool(self.entity_description.is_on_fn(self.coordinator.data)) - return None + if (is_on := self.entity_description.is_on_fn(self.coordinator.data)) is None: + return None + return bool(is_on) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" diff --git a/homeassistant/components/fully_kiosk/translations/he.json b/homeassistant/components/fully_kiosk/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..2a59e340e54ac762deb4a36182e63fbdebfcf26a --- /dev/null +++ b/homeassistant/components/fully_kiosk/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "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": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fully_kiosk/translations/nb.json b/homeassistant/components/fully_kiosk/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/fully_kiosk/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/nb.json b/homeassistant/components/garages_amsterdam/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/pl.json b/homeassistant/components/garages_amsterdam/translations/pl.json index a9f220d9bfcad99311286a0c948deb997ac4f305..d8207f45e836e9ee6fa4335007e5f58fabe88502 100644 --- a/homeassistant/components/garages_amsterdam/translations/pl.json +++ b/homeassistant/components/garages_amsterdam/translations/pl.json @@ -14,5 +14,5 @@ } } }, - "title": "Parkingi Amsterdamie" + "title": "Parkingi w Amsterdamie" } \ No newline at end of file diff --git a/homeassistant/components/gaviota/manifest.json b/homeassistant/components/gaviota/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..2581f3a505efa66268163662ac04fecfda3f3c08 --- /dev/null +++ b/homeassistant/components/gaviota/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "gaviota", + "name": "Gaviota", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py index 56f17adc9925cd7e8e723b8875778344a8d4c1c9..269da061b58b104db5e47ad5b0514c21aa2055d5 100644 --- a/homeassistant/components/gdacs/__init__.py +++ b/homeassistant/components/gdacs/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_RADIUS, CONF_SCAN_INTERVAL, - CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_KILOMETERS, LENGTH_MILES, ) from homeassistant.core import HomeAssistant, callback @@ -19,7 +19,8 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from homeassistant.util.unit_system import METRIC_SYSTEM +from homeassistant.util.unit_conversion import DistanceConverter +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( CONF_CATEGORIES, @@ -87,8 +88,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b feeds = hass.data[DOMAIN].setdefault(FEED, {}) radius = config_entry.data[CONF_RADIUS] - if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: - radius = METRIC_SYSTEM.length(radius, LENGTH_MILES) + if hass.config.units is US_CUSTOMARY_SYSTEM: + radius = DistanceConverter.convert(radius, LENGTH_MILES, LENGTH_KILOMETERS) # Create feed entity manager for all platforms. manager = GdacsFeedEntityManager(hass, config_entry, radius) feeds[config_entry.entry_id] = manager diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index 715ac7796688f38df4c45ee08e82f32ebaf04d07..5d3b8f3375b39e180717f5289069624db6c98cf7 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -10,16 +10,13 @@ from aio_georss_gdacs.feed_entry import GdacsFeedEntry from homeassistant.components.geo_location import GeolocationEvent from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_UNIT_SYSTEM_IMPERIAL, - LENGTH_KILOMETERS, - LENGTH_MILES, -) +from homeassistant.const import LENGTH_KILOMETERS, LENGTH_MILES 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_platform import AddEntitiesCallback -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_conversion import DistanceConverter +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import GdacsFeedEntityManager from .const import DEFAULT_ICON, DOMAIN, FEED @@ -110,7 +107,7 @@ class GdacsEvent(GeolocationEvent): async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" - if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + if self.hass.config.units is US_CUSTOMARY_SYSTEM: self._attr_unit_of_measurement = LENGTH_MILES self._remove_signal_delete = async_dispatcher_connect( self.hass, f"gdacs_delete_{self._external_id}", self._delete_callback @@ -152,9 +149,9 @@ class GdacsEvent(GeolocationEvent): event_name = f"{feed_entry.country} ({feed_entry.event_id})" self._attr_name = f"{feed_entry.event_type}: {event_name}" # Convert distance if not metric system. - if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: - self._attr_distance = IMPERIAL_SYSTEM.length( - feed_entry.distance_to_home, LENGTH_KILOMETERS + if self.hass.config.units is US_CUSTOMARY_SYSTEM: + self._attr_distance = DistanceConverter.convert( + feed_entry.distance_to_home, LENGTH_KILOMETERS, LENGTH_MILES ) else: self._attr_distance = feed_entry.distance_to_home diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 514264f919e3b3767cb99d670bcd84a37b5de8af..09f52705734a44c80436deb31dbe2d82588cde7c 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -3,17 +3,21 @@ from __future__ import annotations from collections.abc import Mapping import contextlib +from datetime import datetime from errno import EHOSTUNREACH, EIO import io import logging from typing import Any import PIL +from aiohttp import web from async_timeout import timeout from httpx import HTTPStatusError, RequestError, TimeoutException import voluptuous as vol import yarl +from homeassistant.components.camera import CAMERA_IMAGE_TIMEOUT, _async_get_image +from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, @@ -33,14 +37,15 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import FlowResult, UnknownFlow from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.httpx_client import get_async_client from homeassistant.util import slugify -from .camera import generate_auth +from .camera import GenericCamera, generate_auth from .const import ( + CONF_CONFIRMED_OK, CONF_CONTENT_TYPE, CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, @@ -62,6 +67,7 @@ DEFAULT_DATA = { } SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"} +IMAGE_PREVIEWS_ACTIVE = "previews" def build_schema( @@ -190,6 +196,7 @@ def slug( hass: HomeAssistant, template: str | template_helper.Template | None ) -> str | None: """Convert a camera url into a string suitable for a camera name.""" + url = "" if not template: return None if not isinstance(template, template_helper.Template): @@ -197,10 +204,8 @@ def slug( try: url = template.async_render(parse_result=False) return slugify(yarl.URL(url).host) - except TemplateError as err: - _LOGGER.error("Syntax error in '%s': %s", template.template, err) - except (ValueError, TypeError) as err: - _LOGGER.error("Syntax error in '%s': %s", url, err) + except (ValueError, TemplateError, TypeError) as err: + _LOGGER.error("Syntax error in '%s': %s", template, err) return None @@ -261,6 +266,16 @@ async def async_test_stream( return {} +def register_preview(hass: HomeAssistant): + """Set up previews for camera feeds during config flow.""" + hass.data.setdefault(DOMAIN, {}) + + if not hass.data[DOMAIN].get(IMAGE_PREVIEWS_ACTIVE): + _LOGGER.debug("Registering camera image preview handler") + hass.http.register_view(CameraImagePreview(hass)) + hass.data[DOMAIN][IMAGE_PREVIEWS_ACTIVE] = True + + class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow for generic IP camera.""" @@ -268,8 +283,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize Generic ConfigFlow.""" - self.cached_user_input: dict[str, Any] = {} - self.cached_title = "" + self.user_input: dict[str, Any] = {} + self.title = "" @staticmethod def async_get_options_flow( @@ -314,19 +329,45 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): # The automatically generated still image that stream generates # is always jpeg user_input[CONF_CONTENT_TYPE] = "image/jpeg" - - return self.async_create_entry( - title=name, data={}, options=user_input - ) + self.user_input = user_input + self.title = name + + # temporary preview for user to check the image + self.context["preview_cam"] = user_input + return await self.async_step_user_confirm_still() + elif self.user_input: + user_input = self.user_input else: user_input = DEFAULT_DATA.copy() - return self.async_show_form( step_id="user", data_schema=build_schema(user_input), errors=errors, ) + async def async_step_user_confirm_still( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user clicking confirm after still preview.""" + if user_input: + if not user_input.get(CONF_CONFIRMED_OK): + return await self.async_step_user() + return self.async_create_entry( + title=self.title, data={}, options=self.user_input + ) + register_preview(self.hass) + preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}" + return self.async_show_form( + step_id="user_confirm_still", + data_schema=vol.Schema( + { + vol.Required(CONF_CONFIRMED_OK, default=False): bool, + } + ), + description_placeholders={"preview_url": preview_url}, + errors=None, + ) + 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. @@ -353,8 +394,7 @@ class GenericOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Generic IP Camera options flow.""" self.config_entry = config_entry - self.cached_user_input: dict[str, Any] = {} - self.cached_title = "" + self.user_input: dict[str, Any] = {} async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -369,9 +409,7 @@ class GenericOptionsFlowHandler(OptionsFlow): ) 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: - title = 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 @@ -397,10 +435,10 @@ class GenericOptionsFlowHandler(OptionsFlow): ), ), } - return self.async_create_entry( - title=title, - data=data, - ) + self.user_input = data + # temporary preview for user to check the image + self.context["preview_cam"] = data + return await self.async_step_confirm_still() return self.async_show_form( step_id="init", data_schema=build_schema( @@ -410,3 +448,61 @@ class GenericOptionsFlowHandler(OptionsFlow): ), errors=errors, ) + + async def async_step_confirm_still( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user clicking confirm after still preview.""" + if user_input: + if not user_input.get(CONF_CONFIRMED_OK): + return await self.async_step_init() + return self.async_create_entry( + title=self.config_entry.title, + data=self.user_input, + ) + register_preview(self.hass) + preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}" + return self.async_show_form( + step_id="confirm_still", + data_schema=vol.Schema( + { + vol.Required(CONF_CONFIRMED_OK, default=False): bool, + } + ), + description_placeholders={"preview_url": preview_url}, + errors=None, + ) + + +class CameraImagePreview(HomeAssistantView): + """Camera view to temporarily serve an image.""" + + url = "/api/generic/preview_flow_image/{flow_id}" + name = "api:generic:preview_flow_image" + requires_auth = False + + def __init__(self, hass: HomeAssistant) -> None: + """Initialise.""" + self.hass = hass + + async def get(self, request: web.Request, flow_id: str) -> web.Response: + """Start a GET request.""" + _LOGGER.debug("processing GET request for flow_id=%s", flow_id) + try: + flow = self.hass.config_entries.flow.async_get(flow_id) + except UnknownFlow: + try: + flow = self.hass.config_entries.options.async_get(flow_id) + except UnknownFlow as exc: + _LOGGER.warning("Unknown flow while getting image preview") + raise web.HTTPNotFound() from exc + user_input = flow["context"]["preview_cam"] + camera = GenericCamera(self.hass, user_input, flow_id, "preview") + if not camera.is_on: + _LOGGER.debug("Camera is off") + raise web.HTTPServiceUnavailable() + image = await _async_get_image( + camera, + CAMERA_IMAGE_TIMEOUT, + ) + return web.Response(body=image.content, content_type=image.content_type) diff --git a/homeassistant/components/generic/const.py b/homeassistant/components/generic/const.py index eb0d81d493cfce6b6b587619c99b638bea41187a..eb3769094222c2c8e3003d704b18716e096e0fd6 100644 --- a/homeassistant/components/generic/const.py +++ b/homeassistant/components/generic/const.py @@ -2,6 +2,7 @@ DOMAIN = "generic" DEFAULT_NAME = "Generic Camera" +CONF_CONFIRMED_OK = "confirmed_ok" CONF_CONTENT_TYPE = "content_type" CONF_LIMIT_REFETCH_TO_URL_CHANGE = "limit_refetch_to_url_change" CONF_STILL_IMAGE_URL = "still_image_url" diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 8749a45d3de077b9dca76563aa8481d970bdbcdc..83b34f73dc844f194d50339cdcc93bd8ee299250 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -2,7 +2,8 @@ "domain": "generic", "name": "Generic Camera", "config_flow": true, - "requirements": ["ha-av==10.0.0b5", "pillow==9.2.0"], + "requirements": ["ha-av==10.0.0", "pillow==9.2.0"], + "dependencies": ["http"], "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 608c85c1379385e931caeda03f439711e93d4927..7c7e44a67e4b1d4c8d5ea4c12690906c980ad509 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -40,8 +40,12 @@ "content_type": "Content Type" } }, - "confirm": { - "description": "[%key:common::config_flow::description::confirm_setup%]" + "user_confirm_still": { + "title": "Preview", + "description": "", + "data": { + "confirmed_ok": "This image looks good." + } } } }, @@ -69,6 +73,13 @@ "data": { "content_type": "[%key:component::generic::config::step::content_type::data::content_type%]" } + }, + "confirm_still": { + "title": "[%key:component::generic::config::step::user_confirm_still::title%]", + "description": "[%key:component::generic::config::step::user_confirm_still::description%]", + "data": { + "confirmed_ok": "[%key:component::generic::config::step::user_confirm_still::data::confirmed_ok%]" + } } }, "error": { diff --git a/homeassistant/components/generic/translations/bg.json b/homeassistant/components/generic/translations/bg.json index ebb2d32c21d741519487a636b219bafd4fe63f74..8c6944af94a8d5ce98e4a165261d0cf63fee85fb 100644 --- a/homeassistant/components/generic/translations/bg.json +++ b/homeassistant/components/generic/translations/bg.json @@ -19,11 +19,17 @@ }, "user": { "data": { - "authentication": "\u0423\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "authentication": "\u0410\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "rtsp_transport": "RTSP \u0442\u0440\u0430\u043d\u0441\u043f\u043e\u0440\u0442\u0435\u043d \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b", "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" } + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "\u0422\u043e\u0432\u0430 \u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435 \u0438\u0437\u0433\u043b\u0435\u0436\u0434\u0430 \u0434\u043e\u0431\u0440\u0435." + }, + "title": "\u041f\u0440\u0435\u0433\u043b\u0435\u0434" } } }, @@ -32,6 +38,12 @@ "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { + "confirm_still": { + "data": { + "confirmed_ok": "\u0422\u043e\u0432\u0430 \u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435 \u0438\u0437\u0433\u043b\u0435\u0436\u0434\u0430 \u0434\u043e\u0431\u0440\u0435." + }, + "title": "\u041f\u0440\u0435\u0433\u043b\u0435\u0434" + }, "content_type": { "data": { "content_type": "\u0422\u0438\u043f \u0441\u044a\u0434\u044a\u0440\u0436\u0430\u043d\u0438\u0435" @@ -40,7 +52,7 @@ }, "init": { "data": { - "authentication": "\u0423\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "authentication": "\u0410\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "rtsp_transport": "RTSP \u0442\u0440\u0430\u043d\u0441\u043f\u043e\u0440\u0442\u0435\u043d \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b", "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" diff --git a/homeassistant/components/generic/translations/ca.json b/homeassistant/components/generic/translations/ca.json index 90e12b8ea6960904dbcdd460fd22d3e0093817cc..8a03666919ee98f166a1fe503ef88a0e65e40bd1 100644 --- a/homeassistant/components/generic/translations/ca.json +++ b/homeassistant/components/generic/translations/ca.json @@ -45,6 +45,13 @@ "verify_ssl": "Verifica el certificat SSL" }, "description": "Introdueix la configuraci\u00f3 de connexi\u00f3 amb la c\u00e0mera." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "La imatge es veu b\u00e9." + }, + "description": "", + "title": "Vista pr\u00e8via" } } }, @@ -68,6 +75,13 @@ "unknown": "Error inesperat" }, "step": { + "confirm_still": { + "data": { + "confirmed_ok": "La imatge es veu b\u00e9." + }, + "description": "", + "title": "Vista pr\u00e8via" + }, "content_type": { "data": { "content_type": "Tipus de contingut" diff --git a/homeassistant/components/generic/translations/de.json b/homeassistant/components/generic/translations/de.json index 57d15a8efea57078179c1981e58729fcf0f1d979..503f78fea32dcedbd337c9637aa6484662c6fbe8 100644 --- a/homeassistant/components/generic/translations/de.json +++ b/homeassistant/components/generic/translations/de.json @@ -45,6 +45,13 @@ "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, "description": "Gib die Einstellungen f\u00fcr die Verbindung mit der Kamera ein." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "Dieses Bild sieht gut aus." + }, + "description": "", + "title": "Vorschau" } } }, @@ -68,6 +75,13 @@ "unknown": "Unerwarteter Fehler" }, "step": { + "confirm_still": { + "data": { + "confirmed_ok": "Dieses Bild sieht gut aus." + }, + "description": "", + "title": "Vorschau" + }, "content_type": { "data": { "content_type": "Inhaltstyp" diff --git a/homeassistant/components/generic/translations/el.json b/homeassistant/components/generic/translations/el.json index 29063cdc216aeeb0da37a7aace0ffc68d7d56f2f..eda806cc137948cb718bf7714a7c87d02aa7a10f 100644 --- a/homeassistant/components/generic/translations/el.json +++ b/homeassistant/components/generic/translations/el.json @@ -45,6 +45,13 @@ "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" }, "description": "\u0395\u03b9\u03c3\u03b1\u03b3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03b9\u03c2 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03b9\u03c2 \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03c3\u03c5\u03bd\u03b4\u03b5\u03b8\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7\u03bd \u03ba\u03ac\u03bc\u03b5\u03c1\u03b1." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "\u0391\u03c5\u03c4\u03ae \u03b7 \u03b5\u03b9\u03ba\u03cc\u03bd\u03b1 \u03c6\u03b1\u03af\u03bd\u03b5\u03c4\u03b1\u03b9 \u03ba\u03b1\u03bb\u03ae." + }, + "description": "! [\u03a0\u03c1\u03bf\u03b5\u03c0\u03b9\u03c3\u03ba\u03cc\u03c0\u03b7\u03c3\u03b7 \u03c3\u03c4\u03b1\u03c4\u03b9\u03ba\u03ae\u03c2 \u03b5\u03b9\u03ba\u03cc\u03bd\u03b1\u03c2 \u03ba\u03ac\u03bc\u03b5\u03c1\u03b1\u03c2] ({preview_url})", + "title": "\u03a0\u03c1\u03bf\u03b5\u03c0\u03b9\u03c3\u03ba\u03cc\u03c0\u03b7\u03c3\u03b7" } } }, diff --git a/homeassistant/components/generic/translations/en.json b/homeassistant/components/generic/translations/en.json index a4e967182258133c764f7a023d90ab55bc9aa5a3..a9ea9a82d13ed0ec961482b9f1b9e3eeebddf8ed 100644 --- a/homeassistant/components/generic/translations/en.json +++ b/homeassistant/components/generic/translations/en.json @@ -45,6 +45,13 @@ "verify_ssl": "Verify SSL certificate" }, "description": "Enter the settings to connect to the camera." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "This image looks good." + }, + "description": "", + "title": "Preview" } } }, @@ -68,6 +75,13 @@ "unknown": "Unexpected error" }, "step": { + "confirm_still": { + "data": { + "confirmed_ok": "This image looks good." + }, + "description": "", + "title": "Preview" + }, "content_type": { "data": { "content_type": "Content Type" diff --git a/homeassistant/components/generic/translations/es.json b/homeassistant/components/generic/translations/es.json index ff3ce5d4a914546bc1ff9979d59c64884101d951..42362863623f5bfd0ba7ef4ab4d5160e35dc587e 100644 --- a/homeassistant/components/generic/translations/es.json +++ b/homeassistant/components/generic/translations/es.json @@ -45,6 +45,13 @@ "verify_ssl": "Verificar el certificado SSL" }, "description": "Introduce los ajustes para conectarte a la c\u00e1mara." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "Esta imagen se ve bien." + }, + "description": "", + "title": "Vista previa" } } }, @@ -68,6 +75,13 @@ "unknown": "Error inesperado" }, "step": { + "confirm_still": { + "data": { + "confirmed_ok": "Esta imagen se ve bien." + }, + "description": "", + "title": "Vista previa" + }, "content_type": { "data": { "content_type": "Tipos de contenido" diff --git a/homeassistant/components/generic/translations/et.json b/homeassistant/components/generic/translations/et.json index 2746f84b9a14d0d6b74a3ae107371d21e3f89ebb..e89e968a2f9b1289caadb0a071b5fc4a6ea0d408 100644 --- a/homeassistant/components/generic/translations/et.json +++ b/homeassistant/components/generic/translations/et.json @@ -45,6 +45,13 @@ "verify_ssl": "Kontrolli SSL sertifikaati" }, "description": "Sisesta s\u00e4tted kaameraga \u00fchenduse loomiseks." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "See pilt n\u00e4eb hea v\u00e4lja." + }, + "description": "", + "title": "Eelvaade" } } }, @@ -68,6 +75,13 @@ "unknown": "Ootamatu t\u00f5rge" }, "step": { + "confirm_still": { + "data": { + "confirmed_ok": "Pilt tundub OK" + }, + "description": "", + "title": "Eelvaade" + }, "content_type": { "data": { "content_type": "Sisu t\u00fc\u00fcp" diff --git a/homeassistant/components/generic/translations/fr.json b/homeassistant/components/generic/translations/fr.json index 2c992c2fa4f44c49ecf0dc573232d783b7dffdfa..d6c057d4da121cee684f919d1dc04bb04744e0ae 100644 --- a/homeassistant/components/generic/translations/fr.json +++ b/homeassistant/components/generic/translations/fr.json @@ -45,6 +45,9 @@ "verify_ssl": "V\u00e9rifier le certificat SSL" }, "description": "Saisissez les param\u00e8tres de connexion \u00e0 la cam\u00e9ra." + }, + "user_confirm_still": { + "title": "Aper\u00e7u" } } }, @@ -68,6 +71,9 @@ "unknown": "Erreur inattendue" }, "step": { + "confirm_still": { + "title": "Aper\u00e7u" + }, "content_type": { "data": { "content_type": "Type de contenu" diff --git a/homeassistant/components/generic/translations/hu.json b/homeassistant/components/generic/translations/hu.json index bf03ec88d9604b3059326fb15a5bfa54ddc1d9b2..a36457999caf4d60d5dc22e9be098e98b0ff64fc 100644 --- a/homeassistant/components/generic/translations/hu.json +++ b/homeassistant/components/generic/translations/hu.json @@ -45,6 +45,13 @@ "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" }, "description": "Adja meg a kamer\u00e1hoz val\u00f3 csatlakoz\u00e1s be\u00e1ll\u00edt\u00e1sait." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "A k\u00e9p megfelel\u0151" + }, + "description": "![Kamerak\u00e9p el\u0151n\u00e9zet] ({preview_url})", + "title": "El\u0151n\u00e9zet" } } }, diff --git a/homeassistant/components/generic/translations/id.json b/homeassistant/components/generic/translations/id.json index 5222c111c588e4c27c0ca9bf31fbed34f0389b18..8cc0ca6aefcac7bfbf9519d64d3ede732a58bc21 100644 --- a/homeassistant/components/generic/translations/id.json +++ b/homeassistant/components/generic/translations/id.json @@ -45,6 +45,13 @@ "verify_ssl": "Verifikasi sertifikat SSL" }, "description": "Masukkan pengaturan untuk terhubung ke kamera." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "Gambar ini terlihat bagus." + }, + "description": "", + "title": "Pratinjau" } } }, diff --git a/homeassistant/components/generic/translations/it.json b/homeassistant/components/generic/translations/it.json index 14d4b6e872026f9e267aa400aecaedbde16640ea..0fd99c649a122d0740ffbd14f4b5474191a06a61 100644 --- a/homeassistant/components/generic/translations/it.json +++ b/homeassistant/components/generic/translations/it.json @@ -45,6 +45,13 @@ "verify_ssl": "Verifica il certificato SSL" }, "description": "Inserisci le impostazioni per connetterti alla fotocamera." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "Questa immagine sembra buona." + }, + "description": "", + "title": "Anteprima" } } }, @@ -68,6 +75,13 @@ "unknown": "Errore imprevisto" }, "step": { + "confirm_still": { + "data": { + "confirmed_ok": "Questa immagine appare bene" + }, + "description": "", + "title": "Anteprima" + }, "content_type": { "data": { "content_type": "Tipo di contenuto" diff --git a/homeassistant/components/generic/translations/ja.json b/homeassistant/components/generic/translations/ja.json index 8e1ba58b4df29e8d1b2451b647c1e68fc8e994d4..f07da6e04fceef8031d480804bc739479e0b83b1 100644 --- a/homeassistant/components/generic/translations/ja.json +++ b/homeassistant/components/generic/translations/ja.json @@ -45,6 +45,9 @@ "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" }, "description": "\u30ab\u30e1\u30e9\u306b\u63a5\u7d9a\u3059\u308b\u305f\u3081\u306e\u8a2d\u5b9a\u3092\u5165\u529b\u3057\u307e\u3059\u3002" + }, + "user_confirm_still": { + "title": "\u30d7\u30ec\u30d3\u30e5\u30fc" } } }, diff --git a/homeassistant/components/generic/translations/nb.json b/homeassistant/components/generic/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..42a62fb5164006152197f8fcff5fcb1863167535 --- /dev/null +++ b/homeassistant/components/generic/translations/nb.json @@ -0,0 +1,12 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + }, + "options": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/generic/translations/no.json b/homeassistant/components/generic/translations/no.json index 23319f0a9382d5cc01b816100b12eaa0627e6e0b..a4c9c27bf6900d598728e66588fda6dea8cdf434 100644 --- a/homeassistant/components/generic/translations/no.json +++ b/homeassistant/components/generic/translations/no.json @@ -45,6 +45,13 @@ "verify_ssl": "Verifisere SSL-sertifikat" }, "description": "Angi innstillingene for \u00e5 koble til kameraet." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "Dette bildet ser bra ut." + }, + "description": "", + "title": "Forh\u00e5ndsvisning" } } }, @@ -68,6 +75,13 @@ "unknown": "Uventet feil" }, "step": { + "confirm_still": { + "data": { + "confirmed_ok": "Dette bildet ser bra ut." + }, + "description": "", + "title": "Forh\u00e5ndsvisning" + }, "content_type": { "data": { "content_type": "Innholdstype" diff --git a/homeassistant/components/generic/translations/pl.json b/homeassistant/components/generic/translations/pl.json index 71c50148957a14122793ef4cbe3ef3853340e038..e4ee551b524d39601d657627560cb2571cc7eca9 100644 --- a/homeassistant/components/generic/translations/pl.json +++ b/homeassistant/components/generic/translations/pl.json @@ -45,6 +45,13 @@ "verify_ssl": "Weryfikacja certyfikatu SSL" }, "description": "Wprowad\u017a ustawienia, aby po\u0142\u0105czy\u0107 si\u0119 z kamer\u0105." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "Ten obraz wygl\u0105da dobrze." + }, + "description": "", + "title": "Podgl\u0105d" } } }, @@ -68,6 +75,13 @@ "unknown": "Nieoczekiwany b\u0142\u0105d" }, "step": { + "confirm_still": { + "data": { + "confirmed_ok": "Ten obraz wygl\u0105da dobrze." + }, + "description": "", + "title": "Podgl\u0105d" + }, "content_type": { "data": { "content_type": "Typ zawarto\u015bci" diff --git a/homeassistant/components/generic/translations/pt-BR.json b/homeassistant/components/generic/translations/pt-BR.json index 86ac7a01efb21bcdaa0337d006ea949cd0e5f2dc..de9be64fd2ade3bc8b2a440b30a4566e83ddc3a9 100644 --- a/homeassistant/components/generic/translations/pt-BR.json +++ b/homeassistant/components/generic/translations/pt-BR.json @@ -45,6 +45,13 @@ "verify_ssl": "Verifique o certificado SSL" }, "description": "Insira as configura\u00e7\u00f5es para se conectar \u00e0 c\u00e2mera." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "Essa imagem parece boa." + }, + "description": "", + "title": "Visualizar" } } }, @@ -68,6 +75,13 @@ "unknown": "Erro inesperado" }, "step": { + "confirm_still": { + "data": { + "confirmed_ok": "Esta imagem parece boa." + }, + "description": "", + "title": "Visualizar" + }, "content_type": { "data": { "content_type": "Tipo de conte\u00fado" diff --git a/homeassistant/components/generic/translations/ru.json b/homeassistant/components/generic/translations/ru.json index 022af07b58b799a3ddbea22fc6830818214190eb..ad7126d85b66c815b732776d6d65971322f49894 100644 --- a/homeassistant/components/generic/translations/ru.json +++ b/homeassistant/components/generic/translations/ru.json @@ -45,6 +45,13 @@ "verify_ssl": "\u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL" }, "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u043a\u0430\u043c\u0435\u0440\u0435." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "\u042d\u0442\u043e \u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435 \u0432\u044b\u0433\u043b\u044f\u0434\u0438\u0442 \u0445\u043e\u0440\u043e\u0448\u043e." + }, + "description": "", + "title": "\u041f\u0440\u0435\u0434\u0432\u0430\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440" } } }, diff --git a/homeassistant/components/generic/translations/sv.json b/homeassistant/components/generic/translations/sv.json index 616931824b9e0b188bdfe6b5ebe228d4e4e2177e..4db8e007a1d8ce283d031c4e09a55f4a141d380e 100644 --- a/homeassistant/components/generic/translations/sv.json +++ b/homeassistant/components/generic/translations/sv.json @@ -45,6 +45,13 @@ "verify_ssl": "Verifiera SSL-certifikat" }, "description": "Skriv in inst\u00e4llningarna f\u00f6r att ansluta till kameran." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "Bilden ser bra ut." + }, + "description": "", + "title": "F\u00f6rhandsvisning" } } }, diff --git a/homeassistant/components/generic/translations/tr.json b/homeassistant/components/generic/translations/tr.json index efce9d014b11d272507a1ffb51a7f1fbbd4b7251..c3561d18e2a57222024d0d6e84e0a9c39f220f8f 100644 --- a/homeassistant/components/generic/translations/tr.json +++ b/homeassistant/components/generic/translations/tr.json @@ -45,6 +45,13 @@ "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" }, "description": "Kameraya ba\u011flanmak i\u00e7in ayarlar\u0131 girin." + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "Bu g\u00f6r\u00fcnt\u00fc iyi g\u00f6r\u00fcn\u00fcyor." + }, + "description": "", + "title": "\u00d6n izleme" } } }, @@ -68,6 +75,13 @@ "unknown": "Beklenmeyen hata" }, "step": { + "confirm_still": { + "data": { + "confirmed_ok": "Bu g\u00f6r\u00fcnt\u00fc iyi g\u00f6r\u00fcn\u00fcyor." + }, + "description": "", + "title": "\u00d6nizleme" + }, "content_type": { "data": { "content_type": "\u0130\u00e7erik T\u00fcr\u00fc" diff --git a/homeassistant/components/generic/translations/zh-Hans.json b/homeassistant/components/generic/translations/zh-Hans.json index f13ba39c5b8147f6f4677aa813df9c0b00f1573d..639aebabfd08a1492e633b56db9c7badbf2987b6 100644 --- a/homeassistant/components/generic/translations/zh-Hans.json +++ b/homeassistant/components/generic/translations/zh-Hans.json @@ -1,4 +1,15 @@ { + "config": { + "step": { + "user_confirm_still": { + "data": { + "confirmed_ok": "\u8fd9\u5f20\u56fe\u7247\u770b\u8d77\u6765\u4e0d\u9519\u3002" + }, + "description": "", + "title": "\u9884\u89c8" + } + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/generic/translations/zh-Hant.json b/homeassistant/components/generic/translations/zh-Hant.json index ded2ea569c44b3d454f39afa7fc2a0951efe6670..09203276fda09307c23d714cdc54ef6cfbda09b5 100644 --- a/homeassistant/components/generic/translations/zh-Hant.json +++ b/homeassistant/components/generic/translations/zh-Hant.json @@ -45,6 +45,13 @@ "verify_ssl": "\u78ba\u8a8d SSL \u8a8d\u8b49" }, "description": "\u8f38\u5165\u651d\u5f71\u6a5f\u9023\u7dda\u8a2d\u5b9a\u3002" + }, + "user_confirm_still": { + "data": { + "confirmed_ok": "\u5f71\u50cf\u633a\u6e05\u6670\u3002" + }, + "description": "", + "title": "\u9810\u89bd" } } }, @@ -68,6 +75,13 @@ "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "step": { + "confirm_still": { + "data": { + "confirmed_ok": "\u5f71\u50cf\u633a\u6e05\u6670\u3002" + }, + "description": "", + "title": "\u9810\u89bd" + }, "content_type": { "data": { "content_type": "\u5167\u5bb9\u985e\u578b" diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 0f111ca3d874eee3ca02c1a89e67251653fccaa9..720c76e766d5b3d2c5dc61788afbc5d1df595a2e 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -282,7 +282,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): return self._target_humidity = humidity - await self._async_operate(force=True) + await self._async_operate() await self.async_update_ha_state() @property diff --git a/homeassistant/components/geocaching/translations/no.json b/homeassistant/components/geocaching/translations/no.json index f0b0b724861026117cbad10778b33f162db46f3a..2dee344a64835eefb902369228f61c11f2621779 100644 --- a/homeassistant/components/geocaching/translations/no.json +++ b/homeassistant/components/geocaching/translations/no.json @@ -7,7 +7,7 @@ "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})", "oauth_error": "Mottatt ugyldige token data.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "create_entry": { "default": "Vellykket godkjenning" diff --git a/homeassistant/components/geonetnz_quakes/__init__.py b/homeassistant/components/geonetnz_quakes/__init__.py index 6c091e71f0507afe2f0c81ddb030e75f06e332a6..e09b4e720e35200ea8320ec6a53d9dc907135a00 100644 --- a/homeassistant/components/geonetnz_quakes/__init__.py +++ b/homeassistant/components/geonetnz_quakes/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_RADIUS, CONF_SCAN_INTERVAL, - CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_KILOMETERS, LENGTH_MILES, ) from homeassistant.core import HomeAssistant, callback @@ -19,7 +19,8 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from homeassistant.util.unit_system import METRIC_SYSTEM +from homeassistant.util.unit_conversion import DistanceConverter +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( CONF_MINIMUM_MAGNITUDE, @@ -94,8 +95,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b feeds = hass.data[DOMAIN].setdefault(FEED, {}) radius = config_entry.data[CONF_RADIUS] - if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: - radius = METRIC_SYSTEM.length(radius, LENGTH_MILES) + if hass.config.units is US_CUSTOMARY_SYSTEM: + radius = DistanceConverter.convert(radius, LENGTH_MILES, LENGTH_KILOMETERS) # Create feed entity manager for all platforms. manager = GeonetnzQuakesFeedEntityManager(hass, config_entry, radius) feeds[config_entry.entry_id] = manager diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index 26ad780d098a65013469f76d5877d77758b33fe7..a530c2d8fdb1f37d43e69b955694f0a23b536530 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -9,17 +9,13 @@ from aio_geojson_geonetnz_quakes.feed_entry import GeonetnzQuakesFeedEntry from homeassistant.components.geo_location import GeolocationEvent from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TIME, - CONF_UNIT_SYSTEM_IMPERIAL, - LENGTH_KILOMETERS, - LENGTH_MILES, -) +from homeassistant.const import ATTR_TIME, LENGTH_KILOMETERS, LENGTH_MILES 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_platform import AddEntitiesCallback -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_conversion import DistanceConverter +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import GeonetnzQuakesFeedEntityManager from .const import DOMAIN, FEED @@ -97,7 +93,7 @@ class GeonetnzQuakesEvent(GeolocationEvent): async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" - if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + if self.hass.config.units is US_CUSTOMARY_SYSTEM: self._attr_unit_of_measurement = LENGTH_MILES self._remove_signal_delete = async_dispatcher_connect( self.hass, @@ -140,9 +136,9 @@ class GeonetnzQuakesEvent(GeolocationEvent): """Update the internal state from the provided feed entry.""" self._attr_name = feed_entry.title # Convert distance if not metric system. - if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: - self._attr_distance = IMPERIAL_SYSTEM.length( - feed_entry.distance_to_home, LENGTH_KILOMETERS + if self.hass.config.units is US_CUSTOMARY_SYSTEM: + self._attr_distance = DistanceConverter.convert( + feed_entry.distance_to_home, LENGTH_KILOMETERS, LENGTH_MILES ) else: self._attr_distance = feed_entry.distance_to_home diff --git a/homeassistant/components/geonetnz_volcano/__init__.py b/homeassistant/components/geonetnz_volcano/__init__.py index a1b6368c8ef759fdd233eca47cae5fd6cfb9389b..da081b4259933c9fe33431d6e1e698fac89d2260 100644 --- a/homeassistant/components/geonetnz_volcano/__init__.py +++ b/homeassistant/components/geonetnz_volcano/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_KILOMETERS, LENGTH_MILES, ) from homeassistant.core import HomeAssistant, callback @@ -22,10 +22,17 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from homeassistant.util.unit_system import METRIC_SYSTEM +from homeassistant.util.unit_conversion import DistanceConverter from .config_flow import configured_instances -from .const import DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN, FEED, PLATFORMS +from .const import ( + DEFAULT_RADIUS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + FEED, + IMPERIAL_UNITS, + PLATFORMS, +) _LOGGER = logging.getLogger(__name__) @@ -84,8 +91,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b radius = config_entry.data[CONF_RADIUS] unit_system = config_entry.data[CONF_UNIT_SYSTEM] - if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: - radius = METRIC_SYSTEM.length(radius, LENGTH_MILES) + if unit_system == IMPERIAL_UNITS: + radius = DistanceConverter.convert(radius, LENGTH_MILES, LENGTH_KILOMETERS) # Create feed entity manager for all platforms. manager = GeonetnzVolcanoFeedEntityManager(hass, config_entry, radius, unit_system) hass.data[DOMAIN][FEED][config_entry.entry_id] = manager diff --git a/homeassistant/components/geonetnz_volcano/config_flow.py b/homeassistant/components/geonetnz_volcano/config_flow.py index 21c9265c1dc4b8ebf6fd53bca9dce871f0ad44cf..34e16970d4f3af9b6f7ed1b5beb2ee6459d4bce3 100644 --- a/homeassistant/components/geonetnz_volcano/config_flow.py +++ b/homeassistant/components/geonetnz_volcano/config_flow.py @@ -8,13 +8,18 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .const import DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import ( + DEFAULT_RADIUS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + IMPERIAL_UNITS, + METRIC_UNITS, +) @callback @@ -57,10 +62,10 @@ class GeonetnzVolcanoFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if identifier in configured_instances(self.hass): return await self._show_form({"base": "already_configured"}) - if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: - user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL + if self.hass.config.units is US_CUSTOMARY_SYSTEM: + user_input[CONF_UNIT_SYSTEM] = IMPERIAL_UNITS else: - user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC + user_input[CONF_UNIT_SYSTEM] = METRIC_UNITS scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) user_input[CONF_SCAN_INTERVAL] = scan_interval.total_seconds() diff --git a/homeassistant/components/geonetnz_volcano/const.py b/homeassistant/components/geonetnz_volcano/const.py index 3a23084aa1f1e4b469f30d148515c55ad075b47a..8c17ff4254443035821297caa1db77e5bec9db89 100644 --- a/homeassistant/components/geonetnz_volcano/const.py +++ b/homeassistant/components/geonetnz_volcano/const.py @@ -18,3 +18,6 @@ DEFAULT_RADIUS = 50.0 DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) PLATFORMS = [Platform.SENSOR] + +IMPERIAL_UNITS = "imperial" +METRIC_UNITS = "metric" diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index add35bfbcd7b9874dcc3d192873c038d3fe49b0c..e78641f99e2f2f71763c1cf924ce7056fce8bfe0 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -6,17 +6,16 @@ import logging from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, - CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_KILOMETERS, + LENGTH_MILES, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_conversion import DistanceConverter from .const import ( ATTR_ACTIVITY, @@ -26,6 +25,7 @@ from .const import ( DEFAULT_ICON, DOMAIN, FEED, + IMPERIAL_UNITS, ) _LOGGER = logging.getLogger(__name__) @@ -112,16 +112,18 @@ class GeonetnzVolcanoSensor(SensorEntity): """Update the internal state from the provided feed entry.""" self._title = feed_entry.title # Convert distance if not metric system. - if self._unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + if self._unit_system == IMPERIAL_UNITS: self._distance = round( - IMPERIAL_SYSTEM.length(feed_entry.distance_to_home, LENGTH_KILOMETERS), + DistanceConverter.convert( + feed_entry.distance_to_home, LENGTH_KILOMETERS, LENGTH_MILES + ), 1, ) else: self._distance = round(feed_entry.distance_to_home, 1) self._latitude = round(feed_entry.coordinates[0], 5) self._longitude = round(feed_entry.coordinates[1], 5) - self._attribution = feed_entry.attribution + self._attr_attribution = feed_entry.attribution self._alert_level = feed_entry.alert_level self._activity = feed_entry.activity self._hazards = feed_entry.hazards @@ -156,7 +158,6 @@ class GeonetnzVolcanoSensor(SensorEntity): attributes = {} for key, value in ( (ATTR_EXTERNAL_ID, self._external_id), - (ATTR_ATTRIBUTION, self._attribution), (ATTR_ACTIVITY, self._activity), (ATTR_HAZARDS, self._hazards), (ATTR_LONGITUDE, self._longitude), diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 20ad912d40cce19c88b773107b9a0bd39751fe1e..d5f01d0f1c31d94eb513f8bc320ccc8c6d95a9f9 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,6 @@ "config_flow": true, "quality_scale": "platinum", "iot_class": "cloud_polling", - "loggers": ["dacite", "gios"] + "loggers": ["dacite", "gios"], + "integration_type": "service" } diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 2d32b8261f324dd1ee5d09187ec5b25559227340..aa528f3d1d3772dd42ff36ef28bb8e5c59d3b43f 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_NAME, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME, @@ -154,6 +153,7 @@ async def async_setup_entry( class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity): """Define an GIOS sensor.""" + _attr_attribution = ATTRIBUTION _attr_has_entity_name = True entity_description: GiosSensorEntityDescription @@ -174,7 +174,6 @@ class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity): ) self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" self._attrs: dict[str, Any] = { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_STATION: self.coordinator.gios.station_name, } self.entity_description = description diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json index 25c7cbdd5129e9d36134e8cdc4205be322a96d15..c6f806e7a13c1b41d4e2600bfb838105375a33c0 100644 --- a/homeassistant/components/github/manifest.json +++ b/homeassistant/components/github/manifest.json @@ -2,7 +2,7 @@ "domain": "github", "name": "GitHub", "documentation": "https://www.home-assistant.io/integrations/github", - "requirements": ["aiogithubapi==22.2.4"], + "requirements": ["aiogithubapi==22.10.1"], "codeowners": ["@timmo001", "@ludeeus"], "iot_class": "cloud_polling", "config_flow": true, diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index f6ae6b6ec17803c521f5314ac21ad8dc40a57921..13f4284acd37349b841c9e02ec5a32a50a204250 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( DATA_GIBIBYTES, DATA_MEBIBYTES, PERCENTAGE, + REVOLUTIONS_PER_MINUTE, STATE_UNAVAILABLE, TEMP_CELSIUS, Platform, @@ -174,7 +175,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( key="fan_speed", type="sensors", name_suffix="Fan speed", - native_unit_of_measurement="RPM", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/goalzero/translations/nb.json b/homeassistant/components/goalzero/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/goalzero/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/goodwe/select.py b/homeassistant/components/goodwe/select.py index c8fa44b7e260519c766c20d29fd9918929741137..32472453c9c7e1b233ee1afc806d688502e4deba 100644 --- a/homeassistant/components/goodwe/select.py +++ b/homeassistant/components/goodwe/select.py @@ -14,18 +14,17 @@ from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER _LOGGER = logging.getLogger(__name__) -INVERTER_OPERATION_MODES = [ - "General mode", - "Off grid mode", - "Backup mode", - "Eco mode", -] - OPERATION_MODE = SelectEntityDescription( key="operation_mode", name="Inverter operation mode", icon="mdi:solar-power", entity_category=EntityCategory.CONFIG, + options=[ + "General mode", + "Off grid mode", + "Backup mode", + "Eco mode", + ], ) @@ -45,14 +44,14 @@ async def async_setup_entry( # Inverter model does not support this setting _LOGGER.debug("Could not read inverter operation mode") else: - if 0 <= active_mode < len(INVERTER_OPERATION_MODES): + if (options := OPERATION_MODE.options) and 0 <= active_mode < len(options): async_add_entities( [ InverterOperationModeEntity( device_info, OPERATION_MODE, inverter, - INVERTER_OPERATION_MODES[active_mode], + options[active_mode], ) ] ) @@ -74,12 +73,11 @@ class InverterOperationModeEntity(SelectEntity): self.entity_description = description self._attr_unique_id = f"{DOMAIN}-{description.key}-{inverter.serial_number}" self._attr_device_info = device_info - self._attr_options = INVERTER_OPERATION_MODES self._attr_current_option = current_mode self._inverter: Inverter = inverter async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self._inverter.set_operation_mode(INVERTER_OPERATION_MODES.index(option)) + await self._inverter.set_operation_mode(self.options.index(option)) self._attr_current_option = option self.async_write_ha_state() diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index bf01f449724efcf678a06b08a574544bd5cc7fd0..22439a05491ab676915280558cc2dfce7318f37d 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta +import logging from typing import Any, cast from goodwe import Inverter, Sensor, SensorKind @@ -36,6 +37,8 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE_INFO, KEY_INVERTER +_LOGGER = logging.getLogger(__name__) + # Sensor name of battery SoC BATTERY_SOC = "battery_soc" @@ -209,7 +212,10 @@ class InverterSensor(CoordinatorEntity, SensorEntity): self._previous_value = 0 self.coordinator.data[self._sensor.id_] = 0 self.async_write_ha_state() - next_midnight = dt_util.start_of_local_day(dt_util.utcnow() + timedelta(days=1)) + _LOGGER.debug("Goodwe reset %s to 0", self.name) + next_midnight = dt_util.start_of_local_day( + dt_util.now() + timedelta(days=1, minutes=1) + ) self._stop_reset = async_track_point_in_time( self.hass, self.async_reset, next_midnight ) @@ -218,7 +224,7 @@ class InverterSensor(CoordinatorEntity, SensorEntity): """Schedule reset task at midnight.""" if self._sensor.id_ in DAILY_RESET: next_midnight = dt_util.start_of_local_day( - dt_util.utcnow() + timedelta(days=1) + dt_util.now() + timedelta(days=1) ) self._stop_reset = async_track_point_in_time( self.hass, self.async_reset, next_midnight diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 33a1216085d6062822a47e8278e670f9a13372f6..c4e51739efd34dac904e21b8d69056e330e907c7 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -36,6 +36,7 @@ from homeassistant.helpers.entity import generate_entity_id from .api import ApiAuthImpl, get_feature_access from .const import ( DATA_SERVICE, + DATA_STORE, DOMAIN, EVENT_DESCRIPTION, EVENT_END_DATE, @@ -49,6 +50,7 @@ from .const import ( EVENT_TYPES_CONF, FeatureAccess, ) +from .store import LocalCalendarStore _LOGGER = logging.getLogger(__name__) @@ -171,6 +173,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ApiAuthImpl(async_get_clientsession(hass), session) ) hass.data[DOMAIN][entry.entry_id][DATA_SERVICE] = calendar_service + hass.data[DOMAIN][entry.entry_id][DATA_STORE] = LocalCalendarStore( + hass, entry.entry_id + ) if entry.unique_id is None: try: @@ -213,6 +218,12 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle removal of a local storage.""" + store = LocalCalendarStore(hass, entry.entry_id) + await store.async_remove() + + async def async_setup_add_event_service( hass: HomeAssistant, calendar_service: GoogleCalendarService, diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 77ed922e511d0d8c1a64d07d01005b16b3f2b33c..ca1228759cde8e43eb85b6040611e939899e576b 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -2,13 +2,17 @@ from __future__ import annotations +import asyncio from datetime import datetime, timedelta import logging from typing import Any -from gcal_sync.api import GoogleCalendarService, ListEventsRequest +from gcal_sync.api import SyncEventsRequest from gcal_sync.exceptions import ApiException from gcal_sync.model import DateOrDatetime, Event +from gcal_sync.store import ScopedCalendarStore +from gcal_sync.sync import CalendarEventSyncManager +from gcal_sync.timeline import Timeline import voluptuous as vol from homeassistant.components.calendar import ( @@ -34,12 +38,12 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, UpdateFailed, ) +from homeassistant.util import dt as dt_util from . import ( CONF_IGNORE_AVAILABILITY, CONF_SEARCH, CONF_TRACK, - DATA_SERVICE, DEFAULT_CONF_OFFSET, DOMAIN, YAML_DEVICES, @@ -49,6 +53,8 @@ from . import ( ) from .api import get_feature_access from .const import ( + DATA_SERVICE, + DATA_STORE, EVENT_DESCRIPTION, EVENT_END_DATE, EVENT_END_DATETIME, @@ -66,6 +72,10 @@ _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +# Avoid syncing super old data on initial syncs. Note that old but active +# recurring events are still included. +SYNC_EVENT_MIN_TIME = timedelta(days=-90) + # 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. @@ -115,6 +125,7 @@ async def async_setup_entry( ) -> None: """Set up the google calendar platform.""" calendar_service = hass.data[DOMAIN][config_entry.entry_id][DATA_SERVICE] + store = hass.data[DOMAIN][config_entry.entry_id][DATA_STORE] try: result = await calendar_service.async_list_calendars() except ApiException as err: @@ -185,12 +196,20 @@ async def async_setup_entry( entity_registry.async_remove( entity_entry.entity_id, ) + request_template = SyncEventsRequest( + calendar_id=calendar_id, + search=data.get(CONF_SEARCH), + start_time=dt_util.now() + SYNC_EVENT_MIN_TIME, + ) + sync = CalendarEventSyncManager( + calendar_service, + store=ScopedCalendarStore(store, unique_id or entity_name), + request_template=request_template, + ) coordinator = CalendarUpdateCoordinator( hass, - calendar_service, + sync, data[CONF_NAME], - calendar_id, - data.get(CONF_SEARCH), ) entities.append( GoogleCalendarEntity( @@ -203,7 +222,7 @@ async def async_setup_entry( ) ) - async_add_entities(entities, True) + async_add_entities(entities) if calendars and new_calendars: @@ -223,16 +242,14 @@ async def async_setup_entry( ) -class CalendarUpdateCoordinator(DataUpdateCoordinator): +class CalendarUpdateCoordinator(DataUpdateCoordinator[Timeline]): """Coordinator for calendar RPC calls.""" def __init__( self, hass: HomeAssistant, - calendar_service: GoogleCalendarService, + sync: CalendarEventSyncManager, name: str, - calendar_id: str, - search: str | None, ) -> None: """Create the Calendar event device.""" super().__init__( @@ -241,38 +258,18 @@ class CalendarUpdateCoordinator(DataUpdateCoordinator): name=name, update_interval=MIN_TIME_BETWEEN_UPDATES, ) - self.calendar_service = calendar_service - self.calendar_id = calendar_id - self._search = search - - async def async_get_events( - self, start_date: datetime, end_date: datetime - ) -> list[Event]: - """Get all events in a specific time frame.""" - request = ListEventsRequest( - 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) - async for result_page in result: - result_items.extend(result_page.items) - except ApiException as err: - self.async_set_update_error(err) - raise HomeAssistantError(str(err)) from err - return result_items + self.sync = sync - async def _async_update_data(self) -> list[Event]: + async def _async_update_data(self) -> Timeline: """Fetch data from API endpoint.""" - request = ListEventsRequest(calendar_id=self.calendar_id, search=self._search) try: - result = await self.calendar_service.async_list_events(request) + await self.sync.run() except ApiException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err - return result.items + + return await self.sync.store_service.async_get_timeline( + dt_util.DEFAULT_TIME_ZONE + ) class GoogleCalendarEntity(CoordinatorEntity, CalendarEntity): @@ -341,16 +338,28 @@ class GoogleCalendarEntity(CoordinatorEntity, CalendarEntity): async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() + # We do not ask for an update with async_add_entities() - # because it will update disabled entities - await self.coordinator.async_request_refresh() - self._apply_coordinator_update() + # because it will update disabled entities. This is started as a + # task to let if sync in the background without blocking startup + async def refresh() -> None: + await self.coordinator.async_request_refresh() + self._apply_coordinator_update() + + asyncio.create_task(refresh()) async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" - result_items = await self.coordinator.async_get_events(start_date, end_date) + if not (timeline := self.coordinator.data): + raise HomeAssistantError( + "Unable to get events: Sync from server has not completed" + ) + result_items = timeline.overlapping( + dt_util.as_local(start_date), + dt_util.as_local(end_date), + ) return [ _get_calendar_event(event) for event in filter(self._event_filter, result_items) @@ -358,12 +367,21 @@ class GoogleCalendarEntity(CoordinatorEntity, CalendarEntity): def _apply_coordinator_update(self) -> None: """Copy state from the coordinator to this entity.""" - events = self.coordinator.data - self._event = _get_calendar_event(next(iter(events))) if events else None - if self._event: + if (timeline := self.coordinator.data) and ( + api_event := next( + filter( + self._event_filter, + timeline.active_after(dt_util.now()), + ), + None, + ) + ): + self._event = _get_calendar_event(api_event) (self._event.summary, self._offset_value) = extract_offset( self._event.summary, self._offset ) + else: + self._event = None @callback def _handle_coordinator_update(self) -> None: @@ -431,7 +449,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> raise ValueError("Missing required fields to set start or end date/datetime") try: - await entity.coordinator.calendar_service.async_create_event( + await entity.coordinator.sync.api.async_create_event( entity.calendar_id, Event( summary=call.data[EVENT_SUMMARY], @@ -442,3 +460,4 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> ) except ApiException as err: raise HomeAssistantError(str(err)) from err + entity.async_write_ha_state() diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index f07958c2e6e0862aea989c43628d1bcc18894261..6a2c1974f6659684fabbbdcb207e9e955a9587fe 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -10,6 +10,7 @@ CONF_CALENDAR_ACCESS = "calendar_access" DATA_CALENDARS = "calendars" DATA_SERVICE = "service" DATA_CONFIG = "config" +DATA_STORE = "store" class FeatureAccess(Enum): diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 39c889786b7d5496962210b905a221be2654136c..f6ebc665cd75065c5af826f6e75d755beffb449b 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.10.0", "oauth2client==4.1.3"], + "requirements": ["gcal-sync==2.2.3", "oauth2client==4.1.3"], "codeowners": ["@allenporter"], "iot_class": "cloud_polling", "loggers": ["googleapiclient"] diff --git a/homeassistant/components/google/store.py b/homeassistant/components/google/store.py new file mode 100644 index 0000000000000000000000000000000000000000..c4d9e4c3e9ce82b119543361bcf66a456bff4e65 --- /dev/null +++ b/homeassistant/components/google/store.py @@ -0,0 +1,53 @@ +"""Google Calendar local storage.""" + +from __future__ import annotations + +import logging +from typing import Any + +from gcal_sync.store import CalendarStore + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STORAGE_KEY_FORMAT = "{domain}.{entry_id}" +STORAGE_VERSION = 1 +# Buffer writes every few minutes (plus guaranteed to be written at shutdown) +STORAGE_SAVE_DELAY_SECONDS = 120 + + +class LocalCalendarStore(CalendarStore): + """Storage for local persistence of calendar and event data.""" + + def __init__(self, hass: HomeAssistant, entry_id: str) -> None: + """Initialize LocalCalendarStore.""" + self._store = Store[dict[str, Any]]( + hass, + STORAGE_VERSION, + STORAGE_KEY_FORMAT.format(domain=DOMAIN, entry_id=entry_id), + private=True, + ) + self._data: dict[str, Any] | None = None + + async def async_load(self) -> dict[str, Any] | None: + """Load data.""" + if self._data is None: + self._data = await self._store.async_load() or {} + return self._data + + async def async_save(self, data: dict[str, Any]) -> None: + """Save data.""" + self._data = data + + def provide_data() -> dict: + return data + + self._store.async_delay_save(provide_data, STORAGE_SAVE_DELAY_SECONDS) + + async def async_remove(self) -> None: + """Remove data.""" + await self._store.async_remove() diff --git a/homeassistant/components/google/translations/de.json b/homeassistant/components/google/translations/de.json index ec3c6d15035dc6a51b2b4b080b3c3e1fb17235fb..377cafe035ed621fbbdc89a6a6899b561980e815 100644 --- a/homeassistant/components/google/translations/de.json +++ b/homeassistant/components/google/translations/de.json @@ -1,6 +1,6 @@ { "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 dr\u00fccke 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" + "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 dr\u00fccke 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." }, "config": { "abort": { diff --git a/homeassistant/components/google/translations/id.json b/homeassistant/components/google/translations/id.json index 6de37cee947785fc390ee6d6552346c0ac814cfd..0488e5abbebff13bcd21ace850ceb464c6731b0f 100644 --- a/homeassistant/components/google/translations/id.json +++ b/homeassistant/components/google/translations/id.json @@ -35,8 +35,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Proses konfigurasi Google Kalender di configuration.yaml dalam proses penghapusan di Home Assistant 2022.9.\n\nKredensial Aplikasi OAuth yang Anda dan setelan akses telah diimpor ke antarmuka secara otomatis. Hapus konfigurasi YAML dari file configuration.yaml Anda dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Google Kalender dalam proses penghapusan" + "description": "Proses konfigurasi Integrasi Google Kalender di configuration.yaml dalam proses penghapusan di Home Assistant 2022.9.\n\nKredensial Aplikasi OAuth yang Anda dan setelan akses telah diimpor ke antarmuka secara otomatis. Hapus konfigurasi YAML dari file configuration.yaml Anda dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Google Kalender dalam proses penghapusan" }, "removed_track_new_yaml": { "description": "Anda telah menonaktifkan pelacakan entitas untuk Google Kalender di configuration.yaml, yang kini tidak lagi didukung. Anda harus secara manual mengubah Opsi Sistem integrasi di antarmuka untuk menonaktifkan entitas yang baru ditemukan di masa datang. Hapus pengaturan track_new dari configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", diff --git a/homeassistant/components/google/translations/no.json b/homeassistant/components/google/translations/no.json index 55103db2a475f7b84b9434b051f568f9ee68d25e..d020a0f294eca8e323f1034553510f419aebadd1 100644 --- a/homeassistant/components/google/translations/no.json +++ b/homeassistant/components/google/translations/no.json @@ -11,7 +11,7 @@ "invalid_access_token": "Ugyldig tilgangstoken", "missing_configuration": "Komponenten er ikke konfigurert, vennligst f\u00f8lg dokumentasjonen", "oauth_error": "Mottatt ugyldige token data.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "timeout_connect": "Tidsavbrudd oppretter forbindelse" }, "create_entry": { diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json index 9ce8085d5e860d7ea3b4e02d0309c91d642a3a30..f919f795181f8948d0e98e5fcc9e8ba06fbdbaff 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.12.1"], + "requirements": ["google-cloud-texttospeech==2.12.3"], "codeowners": ["@lufton"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json index c6690edb52db18a4db77eb86650a00cd40ff4170..d7318e597e1d7a74ba47e0b032d92d9f1b7398c5 100644 --- a/homeassistant/components/google_pubsub/manifest.json +++ b/homeassistant/components/google_pubsub/manifest.json @@ -2,7 +2,7 @@ "domain": "google_pubsub", "name": "Google Pub/Sub", "documentation": "https://www.home-assistant.io/integrations/google_pubsub", - "requirements": ["google-cloud-pubsub==2.11.0"], + "requirements": ["google-cloud-pubsub==2.13.10"], "codeowners": [], "iot_class": "cloud_push" } diff --git a/homeassistant/components/google_sheets/translations/ca.json b/homeassistant/components/google_sheets/translations/ca.json index 35d26781c3a8a2b205828e60fe8d781e7c3bd686..e9cf3ddeb35ad0fc35d72eac9e8f409574e031b9 100644 --- a/homeassistant/components/google_sheets/translations/ca.json +++ b/homeassistant/components/google_sheets/translations/ca.json @@ -27,6 +27,7 @@ "title": "Selecciona el m\u00e8tode d'autenticaci\u00f3" }, "reauth_confirm": { + "description": "La integraci\u00f3 Google Sheets ha de tornar a autenticar-se amb el teu compte", "title": "Reautenticaci\u00f3 de la integraci\u00f3" } } diff --git a/homeassistant/components/google_sheets/translations/de.json b/homeassistant/components/google_sheets/translations/de.json index f203b3e1133b79172677542f2c87b6664ceeddf4..417df6bdb3bb7b9a2a946ee4223989f72db5e893 100644 --- a/homeassistant/components/google_sheets/translations/de.json +++ b/homeassistant/components/google_sheets/translations/de.json @@ -1,6 +1,6 @@ { "application_credentials": { - "description": "Folge den [Anweisungen]({more_info_url}) f\u00fcr den [OAuth-Zustimmungsbildschirm]({oauth_consent_url}), um dem Home Assistant Zugriff auf deine Google Sheets zu geben. Du musst auch Anwendungsnachweise erstellen, die mit deinem Konto verkn\u00fcpft sind:\n1. Gehe zu [Anmeldeinformationen]({oauth_creds_url}) und klicke auf **Anmeldeinformationen erstellen**.\n1. W\u00e4hle aus der Dropdown-Liste **OAuth-Client-ID**.\n1. W\u00e4hle **Webanwendung** f\u00fcr den Anwendungstyp.\n\n" + "description": "Folge den [Anweisungen]({more_info_url}) f\u00fcr den [OAuth-Zustimmungsbildschirm]({oauth_consent_url}), um dem Home Assistant Zugriff auf deine Google Sheets zu geben. Du musst auch Anwendungsnachweise erstellen, die mit deinem Konto verkn\u00fcpft sind:\n1. Gehe zu [Anmeldeinformationen]({oauth_creds_url}) und klicke auf **Anmeldeinformationen erstellen**.\n1. W\u00e4hle aus der Dropdown-Liste **OAuth-Client-ID**.\n1. W\u00e4hle **Webanwendung** f\u00fcr den Anwendungstyp." }, "config": { "abort": { diff --git a/homeassistant/components/google_sheets/translations/el.json b/homeassistant/components/google_sheets/translations/el.json index e7527dcb7d3726ba4aa8177eb18230ac4f0929c1..ec84423dfe5b779162c644453c3911a980a79efb 100644 --- a/homeassistant/components/google_sheets/translations/el.json +++ b/homeassistant/components/google_sheets/translations/el.json @@ -25,6 +25,10 @@ }, "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" + }, + "reauth_confirm": { + "description": "\u0397 \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 \u03c4\u03bf\u03c5 Google Sheets \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03c0\u03b9\u03c3\u03c4\u03bf\u03c0\u03bf\u03b9\u03ae\u03c3\u03b5\u03b9 \u03b5\u03ba \u03bd\u03ad\u03bf\u03c5 \u03c4\u03bf\u03bd \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc \u03c3\u03b1\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" } } } diff --git a/homeassistant/components/google_sheets/translations/et.json b/homeassistant/components/google_sheets/translations/et.json index e1e88192389f81787904b40be1a4f7a13b1da917..fe8433a5de88848a2cc5767bc6c8bfe04909a8c0 100644 --- a/homeassistant/components/google_sheets/translations/et.json +++ b/homeassistant/components/google_sheets/translations/et.json @@ -25,6 +25,10 @@ }, "pick_implementation": { "title": "Vali tuvastusmeetod" + }, + "reauth_confirm": { + "description": "Google Sheets integratsioon peab teie konto uuesti autentima", + "title": "Taastuvasta sidumine" } } } diff --git a/homeassistant/components/google_sheets/translations/he.json b/homeassistant/components/google_sheets/translations/he.json index 412a09eb52aa5d31229426fcf8ffe22dabd41ee0..c2e721784b373588c5f6b6a367abd3e34cd8585a 100644 --- a/homeassistant/components/google_sheets/translations/he.json +++ b/homeassistant/components/google_sheets/translations/he.json @@ -14,6 +14,9 @@ "step": { "pick_implementation": { "title": "\u05d1\u05d7\u05e8 \u05e9\u05d9\u05d8\u05ea \u05d0\u05d9\u05de\u05d5\u05ea" + }, + "reauth_confirm": { + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" } } } diff --git a/homeassistant/components/google_sheets/translations/id.json b/homeassistant/components/google_sheets/translations/id.json index 474fa65b0051df4b507351593b8db5c6b62fdd7f..391a68a272f7a92fd716b7d6a852e2e1fe60845b 100644 --- a/homeassistant/components/google_sheets/translations/id.json +++ b/homeassistant/components/google_sheets/translations/id.json @@ -25,6 +25,10 @@ }, "pick_implementation": { "title": "Pilih Metode Autentikasi" + }, + "reauth_confirm": { + "description": "Integrasi Google Spreadsheet perlu mengautentikasi ulang akun Anda", + "title": "Autentikasi Ulang Integrasi" } } } diff --git a/homeassistant/components/google_sheets/translations/it.json b/homeassistant/components/google_sheets/translations/it.json index 6d8f315a84a1f7f64245dd0827823e15beaf0acf..4a63c95e4a1ad12bf79fcd95f91a966dd9b8b69d 100644 --- a/homeassistant/components/google_sheets/translations/it.json +++ b/homeassistant/components/google_sheets/translations/it.json @@ -25,6 +25,10 @@ }, "pick_implementation": { "title": "Scegli il metodo di autenticazione" + }, + "reauth_confirm": { + "description": "L'integrazione di Fogli Google deve autenticare nuovamente il tuo account", + "title": "Autentica nuovamente l'integrazione" } } } diff --git a/homeassistant/components/google_sheets/translations/ja.json b/homeassistant/components/google_sheets/translations/ja.json index e37b4517358eb158d454a4d2a20bbbf0b04fcd14..5b574e5ee86059584ee3fc42311006532f83f1f6 100644 --- a/homeassistant/components/google_sheets/translations/ja.json +++ b/homeassistant/components/google_sheets/translations/ja.json @@ -17,6 +17,9 @@ }, "pick_implementation": { "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" + }, + "reauth_confirm": { + "title": "\u7d71\u5408\u306e\u518d\u8a8d\u8a3c" } } } diff --git a/homeassistant/components/google_sheets/translations/nb.json b/homeassistant/components/google_sheets/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..462154c139b282c06fbc0aca2c6fdcdc4a4b39e4 --- /dev/null +++ b/homeassistant/components/google_sheets/translations/nb.json @@ -0,0 +1,9 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google_sheets/translations/no.json b/homeassistant/components/google_sheets/translations/no.json index c4cec211828da0e59a4e51491bc08f4e946fafa2..3d7a5358376569a52bde61539c21738afa65e5ea 100644 --- a/homeassistant/components/google_sheets/translations/no.json +++ b/homeassistant/components/google_sheets/translations/no.json @@ -12,7 +12,7 @@ "missing_configuration": "Komponenten er ikke konfigurert, vennligst f\u00f8lg dokumentasjonen", "oauth_error": "Mottatt ugyldige token data.", "open_spreadsheet_failure": "Feil under \u00e5pning av regnearket, se feillogg for detaljer", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "timeout_connect": "Tidsavbrudd oppretter forbindelse", "unknown": "Uventet feil" }, diff --git a/homeassistant/components/google_sheets/translations/pl.json b/homeassistant/components/google_sheets/translations/pl.json index c976b9f1910424b27a607baef36781bc528ab648..1f0ea6a0c263a70d2cf14274f3d0ae9f46894151 100644 --- a/homeassistant/components/google_sheets/translations/pl.json +++ b/homeassistant/components/google_sheets/translations/pl.json @@ -25,6 +25,10 @@ }, "pick_implementation": { "title": "Wybierz metod\u0119 uwierzytelniania" + }, + "reauth_confirm": { + "description": "Integracja Arkusze Google wymaga ponownego uwierzytelnienia Twojego konta", + "title": "Ponownie uwierzytelnij integracj\u0119" } } } diff --git a/homeassistant/components/google_sheets/translations/pt-BR.json b/homeassistant/components/google_sheets/translations/pt-BR.json index e8b4f23a4ed6d92d13e825039cc23a940249edad..acc438b37d59ac3ab173e7a0c64e66fa5e382989 100644 --- a/homeassistant/components/google_sheets/translations/pt-BR.json +++ b/homeassistant/components/google_sheets/translations/pt-BR.json @@ -4,16 +4,16 @@ }, "config": { "abort": { - "already_configured": "A conta j\u00e1 est\u00e1 configurada", - "already_in_progress": "A configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", - "cannot_connect": "Falhou ao conectar", + "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 conectar", "create_spreadsheet_failure": "Erro ao criar planilha, veja o log de erros para detalhes", "invalid_access_token": "Token de acesso inv\u00e1lido", "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", - "oauth_error": "Dados de token inv\u00e1lidos recebidos.", + "oauth_error": "Dados de token recebidos inv\u00e1lidos.", "open_spreadsheet_failure": "Erro ao abrir a planilha, veja o log de erros para detalhes", "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida", - "timeout_connect": "Tempo limite estabelecendo conex\u00e3o", + "timeout_connect": "Tempo limite para estabelecer conex\u00e3o atingido", "unknown": "Erro inesperado" }, "create_entry": { @@ -25,6 +25,10 @@ }, "pick_implementation": { "title": "Escolha o m\u00e9todo de autentica\u00e7\u00e3o" + }, + "reauth_confirm": { + "description": "A integra\u00e7\u00e3o do Planilhas Google precisa autenticar novamente sua conta", + "title": "Reautenticar Integra\u00e7\u00e3o" } } } diff --git a/homeassistant/components/google_sheets/translations/sv.json b/homeassistant/components/google_sheets/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..06c59d33c2a43e600cfb4934efbd20d94c3b1a5e --- /dev/null +++ b/homeassistant/components/google_sheets/translations/sv.json @@ -0,0 +1,35 @@ +{ + "application_credentials": { + "description": "F\u00f6lj [instruktionerna]({more_info_url}) f\u00f6r [OAuth-samtyckessk\u00e4rmen]({oauth_consent_url}) f\u00f6r att ge Home Assistant \u00e5tkomst till dina Google Kalkylark. Du m\u00e5ste ocks\u00e5 skapa applikationsuppgifter kopplade till ditt konto:\n 1. G\u00e5 till [Inloggningsuppgifter]({oauth_creds_url}) och klicka p\u00e5 **Skapa inloggningsuppgifter**.\n 1. V\u00e4lj **OAuth-klient-ID** i rullgardinsmenyn.\n 1. V\u00e4lj **Webbapplikation** f\u00f6r applikationstyp. \n\n" + }, + "config": { + "abort": { + "already_configured": "Konto har redan konfigurerats", + "already_in_progress": "Konfigurationsfl\u00f6det p\u00e5g\u00e5r redan", + "cannot_connect": "Det gick inte att ansluta.", + "create_spreadsheet_failure": "Fel vid skapande av kalkylblad, se fellogg f\u00f6r mer information", + "invalid_access_token": "Ogiltig \u00e5tkomstnyckel", + "missing_configuration": "Komponenten har inte konfigurerats. F\u00f6lj dokumentationen.", + "oauth_error": "Mottog ogiltiga tokendata.", + "open_spreadsheet_failure": "Fel vid \u00f6ppning av kalkylark, se fellogg f\u00f6r detaljer", + "reauth_successful": "\u00c5terautentisering lyckades", + "timeout_connect": "Timeout uppr\u00e4ttar anslutning", + "unknown": "Ov\u00e4ntat fel" + }, + "create_entry": { + "default": "Framg\u00e5ngsrikt autentiserat och kalkylark skapat p\u00e5: {url}" + }, + "step": { + "auth": { + "title": "L\u00e4nka Google-konto" + }, + "pick_implementation": { + "title": "V\u00e4lj autentiseringsmetod" + }, + "reauth_confirm": { + "description": "Google Sheets-integrationen m\u00e5ste autentisera ditt konto p\u00e5 nytt", + "title": "\u00c5terautenticera integration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google_sheets/translations/tr.json b/homeassistant/components/google_sheets/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..b92ca894b570ca9b4794dfadea7ddfbff1a0f65f --- /dev/null +++ b/homeassistant/components/google_sheets/translations/tr.json @@ -0,0 +1,35 @@ +{ + "application_credentials": { + "description": "Home Assistant'\u0131n Google E-Tablolar\u0131n\u0131za eri\u015fmesine izin vermek i\u00e7in [OAuth izin ekran\u0131]( {oauth_consent_url} ) i\u00e7in [talimatlar\u0131]( {more_info_url} ) uygulay\u0131n. Ayr\u0131ca, hesab\u0131n\u0131za 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 **Web uygulamas\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", + "create_spreadsheet_failure": "E-tablo olu\u015fturulurken hata olu\u015ftu, ayr\u0131nt\u0131lar i\u00e7in hata g\u00fcnl\u00fc\u011f\u00fcne bak\u0131n", + "invalid_access_token": "Ge\u00e7ersiz eri\u015fim anahtar\u0131", + "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", + "oauth_error": "Ge\u00e7ersiz anahtar verileri al\u0131nd\u0131.", + "open_spreadsheet_failure": "E-tablo a\u00e7\u0131l\u0131rken hata olu\u015ftu, ayr\u0131nt\u0131lar i\u00e7in hata g\u00fcnl\u00fc\u011f\u00fcne bak\u0131n", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "timeout_connect": "Ba\u011flant\u0131 kurulurken zaman a\u015f\u0131m\u0131", + "unknown": "Beklenmeyen hata" + }, + "create_entry": { + "default": "Ba\u015far\u0131yla do\u011fruland\u0131 ve \u015fu adreste e-tablo olu\u015fturuldu: {url}" + }, + "step": { + "auth": { + "title": "Google Hesab\u0131n\u0131 Ba\u011fla" + }, + "pick_implementation": { + "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" + }, + "reauth_confirm": { + "description": "Google E-Tablolar entegrasyonunun hesab\u0131n\u0131z\u0131 yeniden do\u011frulanmas\u0131 gerekiyor", + "title": "Entegrasyonu Yeniden Do\u011frula" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google_travel_time/__init__.py b/homeassistant/components/google_travel_time/__init__.py index 7d125be502509c3a287933a89365621a25f39d39..b2778a34c1002e9a9c9194562b626649c00fcc8d 100644 --- a/homeassistant/components/google_travel_time/__init__.py +++ b/homeassistant/components/google_travel_time/__init__.py @@ -2,23 +2,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, - async_get, -) PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google Maps Travel Time from a config entry.""" - if entry.unique_id is not None: - hass.config_entries.async_update_entry(entry, unique_id=None) - - ent_reg = async_get(hass) - for entity in async_entries_for_config_entry(ent_reg, entry.entry_id): - ent_reg.async_update_entity(entity.entity_id, new_unique_id=entry.entry_id) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index f2e23b02cc0e2d88b54ddf553e604a0e06b6d48e..d9218566bcb12c0eb4a6a8edde0f93228c05c78d 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -1,14 +1,14 @@ """Config flow for Google Maps Travel Time integration.""" from __future__ import annotations -import logging - import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( ALL_LANGUAGES, @@ -35,10 +35,20 @@ from .const import ( TRAVEL_MODE, TRAVEL_MODEL, UNITS, + UNITS_IMPERIAL, + UNITS_METRIC, ) -from .helpers import is_valid_config_entry +from .helpers import InvalidApiKeyException, UnknownException, validate_config_entry + -_LOGGER = logging.getLogger(__name__) +def default_options(hass: HomeAssistant) -> dict[str, str | None]: + """Get the default options.""" + return { + CONF_MODE: "driving", + CONF_UNITS: ( + UNITS_IMPERIAL if hass.config.units is US_CUSTOMARY_SYSTEM else UNITS_METRIC + ), + } class GoogleOptionsFlow(config_entries.OptionsFlow): @@ -48,7 +58,7 @@ class GoogleOptionsFlow(config_entries.OptionsFlow): """Initialize google options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input=None) -> FlowResult: """Handle the initial step.""" if user_input is not None: time_type = user_input.pop(CONF_TIME_TYPE) @@ -122,25 +132,28 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return GoogleOptionsFlow(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """Handle the initial step.""" errors = {} user_input = user_input or {} if user_input: - if await self.hass.async_add_executor_job( - is_valid_config_entry, - self.hass, - user_input[CONF_API_KEY], - user_input[CONF_ORIGIN], - user_input[CONF_DESTINATION], - ): + try: + await self.hass.async_add_executor_job( + validate_config_entry, + self.hass, + user_input[CONF_API_KEY], + user_input[CONF_ORIGIN], + user_input[CONF_DESTINATION], + ) return self.async_create_entry( title=user_input.get(CONF_NAME, DEFAULT_NAME), data=user_input, + options=default_options(self.hass), ) - - # If we get here, it's because we couldn't connect - errors["base"] = "cannot_connect" + except InvalidApiKeyException: + errors["base"] = "invalid_auth" + except UnknownException: + errors["base"] = "cannot_connect" return self.async_show_form( step_id="user", diff --git a/homeassistant/components/google_travel_time/const.py b/homeassistant/components/google_travel_time/const.py index 893c0e48bd040acaf4148ff966132ae15fb0c00e..efc17b22ec12b653657ec9369b993c7d5348a9e8 100644 --- a/homeassistant/components/google_travel_time/const.py +++ b/homeassistant/components/google_travel_time/const.py @@ -1,6 +1,4 @@ """Constants for Google Travel Time.""" -from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC - DOMAIN = "google_travel_time" ATTRIBUTION = "Powered by Google" @@ -84,4 +82,8 @@ TRANSIT_PREFS = ["less_walking", "fewer_transfers"] TRANSPORT_TYPE = ["bus", "subway", "train", "tram", "rail"] TRAVEL_MODE = ["driving", "walking", "bicycling", "transit"] TRAVEL_MODEL = ["best_guess", "pessimistic", "optimistic"] -UNITS = [CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL] + +# googlemaps library uses "metric" or "imperial" terminology in distance_matrix +UNITS_METRIC = "metric" +UNITS_IMPERIAL = "imperial" +UNITS = [UNITS_METRIC, UNITS_IMPERIAL] diff --git a/homeassistant/components/google_travel_time/helpers.py b/homeassistant/components/google_travel_time/helpers.py index dc8fc32af7a6be0c6375b616784afbea20e64aff..12394a2320952c1a31085b02a7f3971299ab2994 100644 --- a/homeassistant/components/google_travel_time/helpers.py +++ b/homeassistant/components/google_travel_time/helpers.py @@ -1,18 +1,46 @@ """Helpers for Google Time Travel integration.""" +import logging + from googlemaps import Client from googlemaps.distance_matrix import distance_matrix -from googlemaps.exceptions import ApiError +from googlemaps.exceptions import ApiError, Timeout, TransportError +from homeassistant.core import HomeAssistant from homeassistant.helpers.location import find_coordinates +_LOGGER = logging.getLogger(__name__) + -def is_valid_config_entry(hass, api_key, origin, destination): +def validate_config_entry( + hass: HomeAssistant, api_key: str, origin: str, destination: str +) -> None: """Return whether the config entry data is valid.""" - origin = find_coordinates(hass, origin) - destination = find_coordinates(hass, destination) - client = Client(api_key, timeout=10) + resolved_origin = find_coordinates(hass, origin) + resolved_destination = find_coordinates(hass, destination) try: - distance_matrix(client, origin, destination, mode="driving") - except ApiError: - return False - return True + client = Client(api_key, timeout=10) + except ValueError as value_error: + _LOGGER.error("Malformed API key") + raise InvalidApiKeyException from value_error + try: + distance_matrix(client, resolved_origin, resolved_destination, mode="driving") + except ApiError as api_error: + if api_error.status == "REQUEST_DENIED": + _LOGGER.error("Request denied: %s", api_error.message) + raise InvalidApiKeyException from api_error + _LOGGER.error("Unknown error: %s", api_error.message) + raise UnknownException() from api_error + except TransportError as transport_error: + _LOGGER.error("Unknown error: %s", transport_error) + raise UnknownException() from transport_error + except Timeout as timeout_error: + _LOGGER.error("Timeout error") + raise UnknownException() from timeout_error + + +class InvalidApiKeyException(Exception): + """Invalid API Key Error.""" + + +class UnknownException(Exception): + """Unknown API Error.""" diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index df5221dea2f1721772ac5ba833a8b77e64df7cd1..e75db1a29e992c618dd51504a02d882f48e510e7 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -10,9 +10,7 @@ from googlemaps.distance_matrix import distance_matrix from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_API_KEY, - CONF_MODE, CONF_NAME, EVENT_HOMEASSISTANT_STARTED, TIME_MINUTES, @@ -29,10 +27,7 @@ from .const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, CONF_DESTINATION, - CONF_OPTIONS, CONF_ORIGIN, - CONF_TRAVEL_MODE, - CONF_UNITS, DEFAULT_NAME, DOMAIN, ) @@ -58,30 +53,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Google travel time sensor entry.""" - if not config_entry.options: - new_data = config_entry.data.copy() - options = new_data.pop(CONF_OPTIONS, {}) - - if CONF_UNITS not in options: - options[CONF_UNITS] = hass.config.units.name - - if CONF_TRAVEL_MODE in new_data: - wstr = ( - "Google Travel Time: travel_mode is deprecated, please " - "add mode to the options dictionary instead!" - ) - _LOGGER.warning(wstr) - travel_mode = new_data.pop(CONF_TRAVEL_MODE) - if CONF_MODE not in options: - options[CONF_MODE] = travel_mode - - if CONF_MODE not in options: - options[CONF_MODE] = "driving" - - hass.config_entries.async_update_entry( - config_entry, data=new_data, options=options - ) - api_key = config_entry.data[CONF_API_KEY] origin = config_entry.data[CONF_ORIGIN] destination = config_entry.data[CONF_DESTINATION] @@ -99,6 +70,8 @@ async def async_setup_entry( class GoogleTravelTimeSensor(SensorEntity): """Representation of a Google travel time sensor.""" + _attr_attribution = ATTRIBUTION + def __init__(self, config_entry, name, api_key, origin, destination, client): """Initialize the sensor.""" self._name = name @@ -173,7 +146,6 @@ class GoogleTravelTimeSensor(SensorEntity): res["distance"] = _data["distance"]["text"] res["origin"] = self._resolved_origin res["destination"] = self._resolved_destination - res[ATTR_ATTRIBUTION] = ATTRIBUTION return res @property diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index e0043a4b3424deb40d51dd4d4f50376a3e04d7f7..78b84038c7fa01c4d82db43615bc2c45b31a4156 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -13,6 +13,7 @@ } }, "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { @@ -29,6 +30,7 @@ "time_type": "Time Type", "time": "Time", "avoid": "Avoid", + "traffic_mode": "Traffic Mode", "transit_mode": "Transit Mode", "transit_routing_preference": "Transit Routing Preference", "units": "Units" diff --git a/homeassistant/components/google_travel_time/translations/bg.json b/homeassistant/components/google_travel_time/translations/bg.json index 215f8c00629ec40b3e7a475ef003a1419466e847..3965f9c706cc0fda207e2b92ca8bfa8c6820278b 100644 --- a/homeassistant/components/google_travel_time/translations/bg.json +++ b/homeassistant/components/google_travel_time/translations/bg.json @@ -3,6 +3,10 @@ "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": { + "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\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + }, "step": { "user": { "data": { @@ -11,5 +15,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "language": "\u0415\u0437\u0438\u043a", + "units": "\u0415\u0434\u0438\u043d\u0438\u0446\u0438" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/google_travel_time/translations/ca.json b/homeassistant/components/google_travel_time/translations/ca.json index 41ebdd99bc91da80e331241e9b3be170c4405c62..cfd24ef02a86a6ced207aed16db60bf4e8862954 100644 --- a/homeassistant/components/google_travel_time/translations/ca.json +++ b/homeassistant/components/google_travel_time/translations/ca.json @@ -4,7 +4,8 @@ "already_configured": "La ubicaci\u00f3 ja est\u00e0 configurada" }, "error": { - "cannot_connect": "Ha fallat la connexi\u00f3" + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/de.json b/homeassistant/components/google_travel_time/translations/de.json index 701935f53fee0ca72dd1fbf09051f21134308752..24bf9799ee0a60ff6393549a8545105a4bf3a0f9 100644 --- a/homeassistant/components/google_travel_time/translations/de.json +++ b/homeassistant/components/google_travel_time/translations/de.json @@ -4,7 +4,8 @@ "already_configured": "Standort ist bereits konfiguriert" }, "error": { - "cannot_connect": "Verbindung fehlgeschlagen" + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/en.json b/homeassistant/components/google_travel_time/translations/en.json index b0e08c1d63dcab263d3bec3962ff5e7819b659e3..dd03dca1d2f8d57955e900804a0344f17f3f2da0 100644 --- a/homeassistant/components/google_travel_time/translations/en.json +++ b/homeassistant/components/google_travel_time/translations/en.json @@ -4,7 +4,8 @@ "already_configured": "Location is already configured" }, "error": { - "cannot_connect": "Failed to connect" + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" }, "step": { "user": { @@ -27,6 +28,7 @@ "mode": "Travel Mode", "time": "Time", "time_type": "Time Type", + "traffic_mode": "Traffic Mode", "transit_mode": "Transit Mode", "transit_routing_preference": "Transit Routing Preference", "units": "Units" diff --git a/homeassistant/components/google_travel_time/translations/es.json b/homeassistant/components/google_travel_time/translations/es.json index 44d227554cb30cf0ad538f5e95b688c4e9ffb759..54c8320665c858408a3238820041f7feb10fcbed 100644 --- a/homeassistant/components/google_travel_time/translations/es.json +++ b/homeassistant/components/google_travel_time/translations/es.json @@ -4,7 +4,8 @@ "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada" }, "error": { - "cannot_connect": "No se pudo conectar" + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/et.json b/homeassistant/components/google_travel_time/translations/et.json index a93451e9b67ef021bac4939698cd384879669832..0c8a90e89495a6db11889799514384fcfbc25c8d 100644 --- a/homeassistant/components/google_travel_time/translations/et.json +++ b/homeassistant/components/google_travel_time/translations/et.json @@ -4,7 +4,8 @@ "already_configured": "Asukoht on juba m\u00e4\u00e4ratud" }, "error": { - "cannot_connect": "\u00dchendamine nurjus" + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/fr.json b/homeassistant/components/google_travel_time/translations/fr.json index 86761315d583bc79d978cf562fbcbf3c1f6ac2c2..6bf4595e452eb35a6926a50dcce71c5e38ed0bf3 100644 --- a/homeassistant/components/google_travel_time/translations/fr.json +++ b/homeassistant/components/google_travel_time/translations/fr.json @@ -4,7 +4,8 @@ "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" }, "error": { - "cannot_connect": "\u00c9chec de connexion" + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification non valide" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/he.json b/homeassistant/components/google_travel_time/translations/he.json index 0db92654b417c0c6fe0086e245c2ea1e8c053274..b10c8890fd425f0f7d4cdda029f5ee4c674e3503 100644 --- a/homeassistant/components/google_travel_time/translations/he.json +++ b/homeassistant/components/google_travel_time/translations/he.json @@ -4,7 +4,8 @@ "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" }, "error": { - "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + "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" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/hu.json b/homeassistant/components/google_travel_time/translations/hu.json index 03cfdc18b59ef1ffcf8fbb52096c2289eee872c5..3ad294f4fabef6e11a3e32dc11febd652d766e99 100644 --- a/homeassistant/components/google_travel_time/translations/hu.json +++ b/homeassistant/components/google_travel_time/translations/hu.json @@ -4,7 +4,8 @@ "already_configured": "A hely m\u00e1r konfigur\u00e1lva van" }, "error": { - "cannot_connect": "Csatlakoz\u00e1si hiba" + "cannot_connect": "Csatlakoz\u00e1si hiba", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/it.json b/homeassistant/components/google_travel_time/translations/it.json index 484bed6099e53770080bd1fa107be6222216af3f..16dc9f85f665fe70294b481a43676aad7bbfb4fa 100644 --- a/homeassistant/components/google_travel_time/translations/it.json +++ b/homeassistant/components/google_travel_time/translations/it.json @@ -4,7 +4,8 @@ "already_configured": "La posizione \u00e8 gi\u00e0 configurata" }, "error": { - "cannot_connect": "Impossibile connettersi" + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/nl.json b/homeassistant/components/google_travel_time/translations/nl.json index bb088c97ca554c19343a8a718268138039e5fc48..464042702dcc751eb662bed02de9a30dde4b5f72 100644 --- a/homeassistant/components/google_travel_time/translations/nl.json +++ b/homeassistant/components/google_travel_time/translations/nl.json @@ -4,7 +4,8 @@ "already_configured": "Locatie is al geconfigureerd" }, "error": { - "cannot_connect": "Kan geen verbinding maken" + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/no.json b/homeassistant/components/google_travel_time/translations/no.json index f8df651a6fc9ea4a61dbc8bd31268c61e4256ab7..2a056451bbe81f645a54c561859a3802a5f66e99 100644 --- a/homeassistant/components/google_travel_time/translations/no.json +++ b/homeassistant/components/google_travel_time/translations/no.json @@ -4,7 +4,8 @@ "already_configured": "Plasseringen er allerede konfigurert" }, "error": { - "cannot_connect": "Tilkobling mislyktes" + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/pl.json b/homeassistant/components/google_travel_time/translations/pl.json index d6c88cf88224d440743ac64c49ce2d115ec1cb08..0890a4fd350d28cb97a6010a42b5400683cef550 100644 --- a/homeassistant/components/google_travel_time/translations/pl.json +++ b/homeassistant/components/google_travel_time/translations/pl.json @@ -4,7 +4,8 @@ "already_configured": "Lokalizacja jest ju\u017c skonfigurowana" }, "error": { - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/pt-BR.json b/homeassistant/components/google_travel_time/translations/pt-BR.json index b08969910975bc5d0b98a003d18a1b75f4ba4fc9..9b8249b1b518fb6df09fa6a48f677fb738db7ff6 100644 --- a/homeassistant/components/google_travel_time/translations/pt-BR.json +++ b/homeassistant/components/google_travel_time/translations/pt-BR.json @@ -4,7 +4,8 @@ "already_configured": "Localiza\u00e7\u00e3o j\u00e1 est\u00e1 configurada" }, "error": { - "cannot_connect": "Falha ao conectar" + "cannot_connect": "Falha ao conectar", + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/pt.json b/homeassistant/components/google_travel_time/translations/pt.json index 286cd58dd8966be5ae128b7e67f9e10bd0923aef..c61bf4d3ca45d1e5ad727a0e377fb16a8b1887eb 100644 --- a/homeassistant/components/google_travel_time/translations/pt.json +++ b/homeassistant/components/google_travel_time/translations/pt.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/google_travel_time/translations/ru.json b/homeassistant/components/google_travel_time/translations/ru.json index 1e2706de347f6b766c889a5f3d9cfe9cfa57cc31..d506ed4ca5e3337cc07af797dd8cef1d9ba289f2 100644 --- a/homeassistant/components/google_travel_time/translations/ru.json +++ b/homeassistant/components/google_travel_time/translations/ru.json @@ -4,7 +4,8 @@ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." }, "error": { - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/tr.json b/homeassistant/components/google_travel_time/translations/tr.json index 421179ff1a0a49de3a5df65f756c251b68265abb..117ca7797fb14880847062cabd71d112ff86229a 100644 --- a/homeassistant/components/google_travel_time/translations/tr.json +++ b/homeassistant/components/google_travel_time/translations/tr.json @@ -4,7 +4,8 @@ "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" }, "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, "step": { "user": { diff --git a/homeassistant/components/google_travel_time/translations/zh-Hant.json b/homeassistant/components/google_travel_time/translations/zh-Hant.json index f8ac86306a28d92711fc20be9e806aadba8f8258..29bf50f3376538a50dba2be205b5283709f3e98f 100644 --- a/homeassistant/components/google_travel_time/translations/zh-Hant.json +++ b/homeassistant/components/google_travel_time/translations/zh-Hant.json @@ -4,7 +4,8 @@ "already_configured": "\u5ea7\u6a19\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" }, "error": { - "cannot_connect": "\u9023\u7dda\u5931\u6557" + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" }, "step": { "user": { diff --git a/homeassistant/components/govee_ble/translations/hu.json b/homeassistant/components/govee_ble/translations/hu.json index 7ef0d3a63013dc9a7c1814fe3c80d99ab7dede60..e1673194c6d885ee4f8bae1faa811bd0f14444f2 100644 --- a/homeassistant/components/govee_ble/translations/hu.json +++ b/homeassistant/components/govee_ble/translations/hu.json @@ -14,7 +14,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 1563e811fe9d11f76c4eb3206be3ca9f8c641259..71afa5d104bc4cc8296a57e92c2cb9e3640050bc 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -12,13 +12,13 @@ from homeassistant.components import light from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_FLASH, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -114,7 +114,7 @@ async def async_setup_entry( FORWARDED_ATTRIBUTES = frozenset( { ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, @@ -133,8 +133,8 @@ class LightGroup(GroupEntity, LightEntity): _attr_available = False _attr_icon = "mdi:lightbulb-group" - _attr_max_mireds = 500 - _attr_min_mireds = 154 + _attr_max_color_temp_kelvin = 6500 + _attr_min_color_temp_kelvin = 2000 _attr_should_poll = False def __init__( @@ -239,12 +239,14 @@ class LightGroup(GroupEntity, LightEntity): on_states, ATTR_XY_COLOR, reduce=mean_tuple ) - self._attr_color_temp = reduce_attribute(on_states, ATTR_COLOR_TEMP) - self._attr_min_mireds = reduce_attribute( - states, ATTR_MIN_MIREDS, default=154, reduce=min + self._attr_color_temp_kelvin = reduce_attribute( + on_states, ATTR_COLOR_TEMP_KELVIN ) - self._attr_max_mireds = reduce_attribute( - states, ATTR_MAX_MIREDS, default=500, reduce=max + self._attr_min_color_temp_kelvin = reduce_attribute( + states, ATTR_MIN_COLOR_TEMP_KELVIN, default=2000, reduce=min + ) + self._attr_max_color_temp_kelvin = reduce_attribute( + states, ATTR_MAX_COLOR_TEMP_KELVIN, default=6500, reduce=max ) self._attr_effect_list = None diff --git a/homeassistant/components/group/translations/bg.json b/homeassistant/components/group/translations/bg.json index f39166c65d8ceb7520ca97a130562dc01da24428..5d63f29aff04354e020444523a04e7a59668b8af 100644 --- a/homeassistant/components/group/translations/bg.json +++ b/homeassistant/components/group/translations/bg.json @@ -3,12 +3,16 @@ "step": { "binary_sensor": { "data": { + "all": "\u0412\u0441\u0438\u0447\u043a\u0438 \u043e\u0431\u0435\u043a\u0442\u0438", "entities": "\u0427\u043b\u0435\u043d\u043e\u0432\u0435", "name": "\u0418\u043c\u0435" }, "title": "\u041d\u043e\u0432\u0430 \u0433\u0440\u0443\u043f\u0430" }, "cover": { + "data": { + "name": "\u0418\u043c\u0435" + }, "title": "\u0414\u043e\u0431\u0430\u0432\u044f\u043d\u0435 \u043d\u0430 \u0433\u0440\u0443\u043f\u0430" }, "fan": { @@ -68,6 +72,7 @@ }, "light": { "data": { + "all": "\u0412\u0441\u0438\u0447\u043a\u0438 \u043e\u0431\u0435\u043a\u0442\u0438", "entities": "\u0427\u043b\u0435\u043d\u043e\u0432\u0435" } }, diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index 4127b48ae64d421585ddc33ec6811eb589449362..f3f17804fc11a6a6b633d4240c3c1e6cf89c27ab 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -3,7 +3,7 @@ "name": "Growatt", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/growatt_server/", - "requirements": ["growattServer==1.2.2"], + "requirements": ["growattServer==1.2.3"], "codeowners": ["@indykoning", "@muppet3000", "@JasperPlant"], "iot_class": "cloud_polling", "loggers": ["growattServer"] diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 2ea0c23f213d9cc844042edc996b643adf487cdf..9b733d415025e5f775737636a5fe61b5723341fc 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET, STATE_UNKNOWN +from homeassistant.const import CONF_NAME, CONF_OFFSET, STATE_UNKNOWN from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -648,6 +648,11 @@ class GTFSDepartureSensor(SensorEntity): # Assign attributes, icon and name self.update_attributes() + if self._agency: + self._attr_attribution = self._agency.agency_name + else: + self._attr_attribution = None + if self._route: self._icon = ICONS.get(self._route.route_type, ICON) else: @@ -702,11 +707,6 @@ class GTFSDepartureSensor(SensorEntity): elif ATTR_INFO in self._attributes: del self._attributes[ATTR_INFO] - if self._agency: - self._attributes[ATTR_ATTRIBUTION] = self._agency.agency_name - elif ATTR_ATTRIBUTION in self._attributes: - del self._attributes[ATTR_ATTRIBUTION] - # Add extra metadata key = "agency_id" if self._agency and key not in self._attributes: diff --git a/homeassistant/components/guardian/diagnostics.py b/homeassistant/components/guardian/diagnostics.py index d53dcb68fa8cb53690740d4af27f7da565b290c4..b0317167f79937d71b36a3c218889ddb16341c02 100644 --- a/homeassistant/components/guardian/diagnostics.py +++ b/homeassistant/components/guardian/diagnostics.py @@ -5,6 +5,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from . import GuardianData @@ -13,11 +14,15 @@ from .const import CONF_UID, DOMAIN CONF_BSSID = "bssid" CONF_PAIRED_UIDS = "paired_uids" CONF_SSID = "ssid" +CONF_TITLE = "title" TO_REDACT = { CONF_BSSID, CONF_PAIRED_UIDS, CONF_SSID, + # Config entry title and unique ID may contain sensitive data: + CONF_TITLE, + CONF_UNIQUE_ID, CONF_UID, } @@ -29,10 +34,7 @@ async def async_get_config_entry_diagnostics( data: GuardianData = hass.data[DOMAIN][entry.entry_id] return { - "entry": { - "title": entry.title, - "data": async_redact_data(entry.data, TO_REDACT), - }, + "entry": async_redact_data(entry.as_dict(), TO_REDACT), "data": { "valve_controller": { api_category: async_redact_data(coordinator.data, TO_REDACT) diff --git a/homeassistant/components/guardian/manifest.json b/homeassistant/components/guardian/manifest.json index 7fab487563c2ce9a3d172edbae6644fba340dc55..44527f95d29755d9041deadb5178bb0389838814 100644 --- a/homeassistant/components/guardian/manifest.json +++ b/homeassistant/components/guardian/manifest.json @@ -21,5 +21,6 @@ "macaddress": "30AEA4*" } ], - "loggers": ["aioguardian"] + "loggers": ["aioguardian"], + "integration_type": "device" } diff --git a/homeassistant/components/guardian/translations/en.json b/homeassistant/components/guardian/translations/en.json index 1aaf8b888c8d4659682ed844e2fe363c91ed1912..ac87ae365063d735b52a157e2a168a8c294cf633 100644 --- a/homeassistant/components/guardian/translations/en.json +++ b/homeassistant/components/guardian/translations/en.json @@ -29,6 +29,17 @@ } }, "title": "The {deprecated_service} service will be removed" + }, + "replaced_old_entity": { + "fix_flow": { + "step": { + "confirm": { + "description": "Update any automations or scripts that use this entity to instead use `{replacement_entity_id}`.", + "title": "The {old_entity_id} entity will be removed" + } + } + }, + "title": "The {old_entity_id} entity will be removed" } } } \ No newline at end of file diff --git a/homeassistant/components/guardian/translations/et.json b/homeassistant/components/guardian/translations/et.json index 37eee5acf33f2d22aa4dfa3c67c70cde547aaef6..95c98315435308da4e2d9f79f80bbe5f17d808e9 100644 --- a/homeassistant/components/guardian/translations/et.json +++ b/homeassistant/components/guardian/translations/et.json @@ -23,7 +23,7 @@ "fix_flow": { "step": { "confirm": { - "description": "Uuenda k\u00f5iki seda teenust kasutavaid automatiseerimisi v\u00f5i skripte, et need kasutaksid selle asemel teenust `{alternate_service}}, mille siht\u00fcksuse ID on `{alternate_target}}. Seej\u00e4rel kl\u00f5psa allpool nuppu ESITA, et m\u00e4rkida see probleem lahendatuks.", + "description": "V\u00e4rskenda k\u00f5iki automaatikaid v\u00f5i skripte, mis seda teenust kasutavad, et kasutada selle asemel teenust '{alternate_service}', mille sihtolemi ID on '{alternate_target}'.", "title": "Teenus {deprecated_service} eemaldatakse" } } @@ -34,7 +34,7 @@ "fix_flow": { "step": { "confirm": { - "description": "See olem on asendatud olemiga \"{replacement_entity_id}\".", + "description": "Uuenda k\u00f5iki automaatikaid v\u00f5i skripte, mis kasutavad seda olemit, et kasutada selle asemel `{replacement_entity_id}}.", "title": "Olem {old_entity_id} eemaldatakse" } } diff --git a/homeassistant/components/guardian/translations/nl.json b/homeassistant/components/guardian/translations/nl.json index d8ed1242e928c4863d280663c587d4a73e05a94c..0fadd4dbf51b051a4a14ca27d2c09e35e8c2ed5b 100644 --- a/homeassistant/components/guardian/translations/nl.json +++ b/homeassistant/components/guardian/translations/nl.json @@ -17,5 +17,27 @@ "description": "Configureer een lokaal Elexa Guardian-apparaat." } } + }, + "issues": { + "deprecated_service": { + "fix_flow": { + "step": { + "confirm": { + "title": "De {deprecated_service}-service wordt verwijderd" + } + } + }, + "title": "De {deprecated_service}-service wordt verwijderd" + }, + "replaced_old_entity": { + "fix_flow": { + "step": { + "confirm": { + "title": "De {old_entity_id}-entiteit wordt verwijderd" + } + } + }, + "title": "De {old_entity_id}-entiteit wordt verwijderd" + } } } \ No newline at end of file diff --git a/homeassistant/components/guardian/translations/sv.json b/homeassistant/components/guardian/translations/sv.json index af41cc85efeb2df6a5fc10a2ebb507786bf423c1..0912dd4094bafba0c3f754a436259108cee4a349 100644 --- a/homeassistant/components/guardian/translations/sv.json +++ b/homeassistant/components/guardian/translations/sv.json @@ -29,6 +29,17 @@ } }, "title": "Tj\u00e4nsten {deprecated_service} tas bort" + }, + "replaced_old_entity": { + "fix_flow": { + "step": { + "confirm": { + "description": "Uppdatera alla automatiseringar eller skript som anv\u00e4nder denna enhet f\u00f6r att ist\u00e4llet anv\u00e4nda ` {replacement_entity_id} `.", + "title": "{old_entity_id} kommer att tas bort" + } + } + }, + "title": "{old_entity_id} kommer att tas bort" } } } \ No newline at end of file diff --git a/homeassistant/components/guardian/translations/tr.json b/homeassistant/components/guardian/translations/tr.json index e5de0cb73cdc16e518dd31da6003d948a70faeef..c7e557a38f255d2f4b65df7bbd3aefe648270fa7 100644 --- a/homeassistant/components/guardian/translations/tr.json +++ b/homeassistant/components/guardian/translations/tr.json @@ -23,12 +23,23 @@ "fix_flow": { "step": { "confirm": { - "description": "Bu hizmeti kullanan t\u00fcm otomasyonlar\u0131 veya komut dosyalar\u0131n\u0131, bunun yerine \" {alternate_service} {alternate_target} hizmetini kullanacak \u015fekilde g\u00fcncelleyin. Ard\u0131ndan, bu sorunu \u00e7\u00f6z\u00fcld\u00fc olarak i\u015faretlemek i\u00e7in a\u015fa\u011f\u0131daki G\u00d6NDER'i t\u0131klay\u0131n.", - "title": "{deprecated_service} hizmeti kald\u0131r\u0131l\u0131yor" + "description": "Bu hizmeti kullanan t\u00fcm otomasyonlar\u0131 veya komut dosyalar\u0131n\u0131, bunun yerine \" {alternate_service} {alternate_target} hizmetini kullanacak \u015fekilde g\u00fcncelleyin.", + "title": "{deprecated_service} hizmeti kald\u0131r\u0131lacak" } } }, - "title": "{deprecated_service} hizmeti kald\u0131r\u0131l\u0131yor" + "title": "{deprecated_service} hizmeti kald\u0131r\u0131lacak" + }, + "replaced_old_entity": { + "fix_flow": { + "step": { + "confirm": { + "description": "Bunun yerine ` {replacement_entity_id} ` kullanmak i\u00e7in bu varl\u0131\u011f\u0131 kullanan t\u00fcm otomasyonlar\u0131 veya komut dosyalar\u0131n\u0131 g\u00fcncelleyin.", + "title": "{old_entity_id} varl\u0131\u011f\u0131 kald\u0131r\u0131lacak" + } + } + }, + "title": "{old_entity_id} varl\u0131\u011f\u0131 kald\u0131r\u0131lacak" } } } \ No newline at end of file diff --git a/homeassistant/components/habitica/translations/nb.json b/homeassistant/components/habitica/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/habitica/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/translations/bg.json b/homeassistant/components/hangouts/translations/bg.json index 09ffce392a6ac60cd83a0348329f6ab89c1854ec..8d8dae90ce02318cff9430c6e5deea6c608e3fd0 100644 --- a/homeassistant/components/hangouts/translations/bg.json +++ b/homeassistant/components/hangouts/translations/bg.json @@ -19,7 +19,7 @@ "user": { "data": { "authorization_code": "\u041a\u043e\u0434 \u0437\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f (\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c \u0437\u0430 \u0440\u044a\u0447\u043d\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435)", - "email": "E-mail \u0430\u0434\u0440\u0435\u0441", + "email": "\u0418\u043c\u0435\u0439\u043b", "password": "\u041f\u0430\u0440\u043e\u043b\u0430" }, "title": "\u0412\u0445\u043e\u0434 \u0432 Google Hangouts" diff --git a/homeassistant/components/hangouts/translations/nb.json b/homeassistant/components/hangouts/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/hangouts/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hardware/websocket_api.py b/homeassistant/components/hardware/websocket_api.py index 5c4b14570a9446757d4dd759ebb1fd70529bf93a..d8dc5f28fddd4f8b302562779c0d00eb46f6262e 100644 --- a/homeassistant/components/hardware/websocket_api.py +++ b/homeassistant/components/hardware/websocket_api.py @@ -4,6 +4,7 @@ from __future__ import annotations import contextlib from dataclasses import asdict, dataclass from datetime import datetime, timedelta +from typing import Any import psutil_home_assistant as ha_psutil import voluptuous as vol @@ -46,7 +47,7 @@ async def async_setup(hass: HomeAssistant) -> None: ) @websocket_api.async_response async def ws_info( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return hardware info.""" hardware_info = [] @@ -72,7 +73,7 @@ async def ws_info( ) @websocket_api.async_response async def ws_subscribe_system_status( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ): """Subscribe to system status updates.""" diff --git a/homeassistant/components/harmony/translations/nb.json b/homeassistant/components/harmony/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/harmony/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 8535a0c3cc6eb1bee6c07b404b26a4370c0ecb4f..c811b35812e7ea66b12ffef3008a74b8192c2e93 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -77,6 +77,7 @@ from .discovery import HassioServiceInfo, async_setup_discovery_view # noqa: F4 from .handler import HassIO, HassioAPIError, api_data from .http import HassIOView from .ingress import async_setup_ingress_view +from .repairs import SupervisorRepairs from .websocket_api import async_load_websocket_api _LOGGER = logging.getLogger(__name__) @@ -103,6 +104,7 @@ DATA_SUPERVISOR_INFO = "hassio_supervisor_info" DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_STATS = "hassio_addons_stats" +DATA_SUPERVISOR_REPAIRS = "supervisor_repairs" HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) ADDONS_COORDINATOR = "hassio_addons_coordinator" @@ -758,6 +760,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) ) + # Start listening for problems with supervisor and making repairs + hass.data[DATA_SUPERVISOR_REPAIRS] = repairs = SupervisorRepairs(hass, hassio) + await repairs.setup() + return True diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index e37a31ddbd6cf5aa835766397f42db49ee23d32b..64ef7a718a5cbe507fb03e3e6d619cb42b53f501 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -11,19 +11,26 @@ ATTR_CONFIG = "config" ATTR_DATA = "data" ATTR_DISCOVERY = "discovery" ATTR_ENABLE = "enable" +ATTR_ENDPOINT = "endpoint" ATTR_FOLDERS = "folders" +ATTR_HEALTHY = "healthy" ATTR_HOMEASSISTANT = "homeassistant" ATTR_INPUT = "input" +ATTR_METHOD = "method" ATTR_PANELS = "panels" ATTR_PASSWORD = "password" +ATTR_RESULT = "result" +ATTR_SUPPORTED = "supported" +ATTR_TIMEOUT = "timeout" ATTR_TITLE = "title" +ATTR_UNHEALTHY = "unhealthy" +ATTR_UNHEALTHY_REASONS = "unhealthy_reasons" +ATTR_UNSUPPORTED = "unsupported" +ATTR_UNSUPPORTED_REASONS = "unsupported_reasons" +ATTR_UPDATE_KEY = "update_key" ATTR_USERNAME = "username" ATTR_UUID = "uuid" ATTR_WS_EVENT = "event" -ATTR_ENDPOINT = "endpoint" -ATTR_METHOD = "method" -ATTR_RESULT = "result" -ATTR_TIMEOUT = "timeout" X_AUTH_TOKEN = "X-Supervisor-Token" X_INGRESS_PATH = "X-Ingress-Path" @@ -38,6 +45,11 @@ WS_TYPE_EVENT = "supervisor/event" WS_TYPE_SUBSCRIBE = "supervisor/subscribe" EVENT_SUPERVISOR_EVENT = "supervisor_event" +EVENT_SUPERVISOR_UPDATE = "supervisor_update" +EVENT_HEALTH_CHANGED = "health_changed" +EVENT_SUPPORTED_CHANGED = "supported_changed" + +UPDATE_KEY_SUPERVISOR = "supervisor" ATTR_AUTO_UPDATE = "auto_update" ATTR_VERSION = "version" @@ -51,7 +63,6 @@ ATTR_STARTED = "started" ATTR_URL = "url" ATTR_REPOSITORY = "repository" - DATA_KEY_ADDONS = "addons" DATA_KEY_OS = "os" DATA_KEY_SUPERVISOR = "supervisor" diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index e8cbbfc6bf5068ab20f958a37ecef60758e9590b..ee680c98ee0de2efc660e1eeed2a61dcc4ddd07c 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -17,7 +17,7 @@ from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import discovery_flow from .const import ATTR_ADDON, ATTR_CONFIG, ATTR_DISCOVERY, ATTR_UUID -from .handler import HassioAPIError +from .handler import HassIO, HassioAPIError _LOGGER = logging.getLogger(__name__) @@ -27,6 +27,8 @@ class HassioServiceInfo(BaseServiceInfo): """Prepared info from hassio entries.""" config: dict[str, Any] + name: str + slug: str @callback @@ -62,7 +64,7 @@ class HassIODiscovery(HomeAssistantView): name = "api:hassio_push:discovery" url = "/api/hassio_push/discovery/{uuid}" - def __init__(self, hass: HomeAssistant, hassio): + def __init__(self, hass: HomeAssistant, hassio: HassIO) -> None: """Initialize WebView.""" self.hass = hass self.hassio = hassio @@ -86,25 +88,28 @@ class HassIODiscovery(HomeAssistantView): await self.async_process_del(data) return web.Response() - async def async_process_new(self, data): + async def async_process_new(self, data: dict[str, Any]) -> None: """Process add discovery entry.""" - service = data[ATTR_SERVICE] - config_data = data[ATTR_CONFIG] + service: str = data[ATTR_SERVICE] + config_data: dict[str, Any] = data[ATTR_CONFIG] + slug: str = data[ATTR_ADDON] # Read additional Add-on info try: - addon_info = await self.hassio.get_addon_info(data[ATTR_ADDON]) + addon_info = await self.hassio.get_addon_info(slug) except HassioAPIError as err: _LOGGER.error("Can't read add-on info: %s", err) return - config_data[ATTR_ADDON] = addon_info[ATTR_NAME] + + name: str = addon_info[ATTR_NAME] + config_data[ATTR_ADDON] = name # Use config flow discovery_flow.async_create_flow( self.hass, service, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=config_data), + data=HassioServiceInfo(config=config_data, name=name, slug=slug), ) async def async_process_del(self, data): diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 7b3ed697227e77d221cd07d3d441c1384d54b005..ee16bdf815869ba60b03b71bea5a3969f943906a 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -190,6 +190,14 @@ class HassIO: """ return self.send_command(f"/discovery/{uuid}", method="get") + @api_data + def get_resolution_info(self): + """Return data for Supervisor resolution center. + + This method return a coroutine. + """ + return self.send_command("/resolution/info", method="get") + @_api_bool async def update_hass_api(self, http_config, refresh_token): """Update Home Assistant API data on Hass.io.""" diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py new file mode 100644 index 0000000000000000000000000000000000000000..21120d8d52283a6e7ff603da762bd6b5a3ae4368 --- /dev/null +++ b/homeassistant/components/hassio/repairs.py @@ -0,0 +1,185 @@ +"""Supervisor events monitor.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) + +from .const import ( + ATTR_DATA, + ATTR_HEALTHY, + ATTR_SUPPORTED, + ATTR_UNHEALTHY, + ATTR_UNHEALTHY_REASONS, + ATTR_UNSUPPORTED, + ATTR_UNSUPPORTED_REASONS, + ATTR_UPDATE_KEY, + ATTR_WS_EVENT, + DOMAIN, + EVENT_HEALTH_CHANGED, + EVENT_SUPERVISOR_EVENT, + EVENT_SUPERVISOR_UPDATE, + EVENT_SUPPORTED_CHANGED, + UPDATE_KEY_SUPERVISOR, +) +from .handler import HassIO + +ISSUE_ID_UNHEALTHY = "unhealthy_system" +ISSUE_ID_UNSUPPORTED = "unsupported_system" + +INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy" +INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported" + +UNSUPPORTED_REASONS = { + "apparmor", + "connectivity_check", + "content_trust", + "dbus", + "dns_server", + "docker_configuration", + "docker_version", + "cgroup_version", + "job_conditions", + "lxc", + "network_manager", + "os", + "os_agent", + "restart_policy", + "software", + "source_mods", + "supervisor_version", + "systemd", + "systemd_journal", + "systemd_resolved", +} +# Some unsupported reasons also mark the system as unhealthy. If the unsupported reason +# provides no additional information beyond the unhealthy one then skip that repair. +UNSUPPORTED_SKIP_REPAIR = {"privileged"} +UNHEALTHY_REASONS = { + "docker", + "supervisor", + "setup", + "privileged", + "untrusted", +} + + +class SupervisorRepairs: + """Create repairs from supervisor events.""" + + def __init__(self, hass: HomeAssistant, client: HassIO) -> None: + """Initialize supervisor repairs.""" + self._hass = hass + self._client = client + self._unsupported_reasons: set[str] = set() + self._unhealthy_reasons: set[str] = set() + + @property + def unhealthy_reasons(self) -> set[str]: + """Get unhealthy reasons. Returns empty set if system is healthy.""" + return self._unhealthy_reasons + + @unhealthy_reasons.setter + def unhealthy_reasons(self, reasons: set[str]) -> None: + """Set unhealthy reasons. Create or delete repairs as necessary.""" + for unhealthy in reasons - self.unhealthy_reasons: + if unhealthy in UNHEALTHY_REASONS: + translation_key = f"unhealthy_{unhealthy}" + translation_placeholders = None + else: + translation_key = "unhealthy" + translation_placeholders = {"reason": unhealthy} + + async_create_issue( + self._hass, + DOMAIN, + f"{ISSUE_ID_UNHEALTHY}_{unhealthy}", + is_fixable=False, + learn_more_url=f"{INFO_URL_UNHEALTHY}/{unhealthy}", + severity=IssueSeverity.CRITICAL, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + for fixed in self.unhealthy_reasons - reasons: + async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNHEALTHY}_{fixed}") + + self._unhealthy_reasons = reasons + + @property + def unsupported_reasons(self) -> set[str]: + """Get unsupported reasons. Returns empty set if system is supported.""" + return self._unsupported_reasons + + @unsupported_reasons.setter + def unsupported_reasons(self, reasons: set[str]) -> None: + """Set unsupported reasons. Create or delete repairs as necessary.""" + for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons: + if unsupported in UNSUPPORTED_REASONS: + translation_key = f"unsupported_{unsupported}" + translation_placeholders = None + else: + translation_key = "unsupported" + translation_placeholders = {"reason": unsupported} + + async_create_issue( + self._hass, + DOMAIN, + f"{ISSUE_ID_UNSUPPORTED}_{unsupported}", + is_fixable=False, + learn_more_url=f"{INFO_URL_UNSUPPORTED}/{unsupported}", + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + for fixed in self.unsupported_reasons - (reasons - UNSUPPORTED_SKIP_REPAIR): + async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNSUPPORTED}_{fixed}") + + self._unsupported_reasons = reasons + + async def setup(self) -> None: + """Create supervisor events listener.""" + await self.update() + + async_dispatcher_connect( + self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_repairs + ) + + async def update(self) -> None: + """Update repairs from Supervisor resolution center.""" + data = await self._client.get_resolution_info() + self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) + self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) + + @callback + def _supervisor_events_to_repairs(self, event: dict[str, Any]) -> None: + """Create repairs from supervisor events.""" + if ATTR_WS_EVENT not in event: + return + + if ( + event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE + and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR + ): + self._hass.async_create_task(self.update()) + + elif event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED: + self.unhealthy_reasons = ( + set() + if event[ATTR_DATA][ATTR_HEALTHY] + else set(event[ATTR_DATA][ATTR_UNHEALTHY_REASONS]) + ) + + elif event[ATTR_WS_EVENT] == EVENT_SUPPORTED_CHANGED: + self.unsupported_reasons = ( + set() + if event[ATTR_DATA][ATTR_SUPPORTED] + else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS]) + ) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 90142bd453f0b9c47e8c826d3daf3b7144c86465..7cda053f43a426ba5a4d8eb90e602a399940f36d 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -15,5 +15,115 @@ "update_channel": "Update Channel", "version_api": "Version API" } + }, + "issues": { + "unhealthy": { + "title": "Unhealthy system - {reason}", + "description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this." + }, + "unhealthy_docker": { + "title": "Unhealthy system - Docker misconfigured", + "description": "System is currently unhealthy because Docker is configured incorrectly. Use the link to learn more and how to fix this." + }, + "unhealthy_supervisor": { + "title": "Unhealthy system - Supervisor update failed", + "description": "System is currently unhealthy because an attempt to update Supervisor to the latest version has failed. Use the link to learn more and how to fix this." + }, + "unhealthy_setup": { + "title": "Unhealthy system - Setup failed", + "description": "System is currently unhealthy because setup failed to complete. There are a number of reasons this can occur, use the link to learn more and how to fix this." + }, + "unhealthy_privileged": { + "title": "Unhealthy system - Not privileged", + "description": "System is currently unhealthy because it does not have privileged access to the docker runtime. Use the link to learn more and how to fix this." + }, + "unhealthy_untrusted": { + "title": "Unhealthy system - Untrusted code", + "description": "System is currently unhealthy because it has detected untrusted code or images in use. Use the link to learn more and how to fix this." + }, + "unsupported": { + "title": "Unsupported system - {reason}", + "description": "System is unsupported due to {reason}. Use the link to learn more and how to fix this." + }, + "unsupported_apparmor": { + "title": "Unsupported system - AppArmor issues", + "description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. Use the link to learn more and how to fix this." + }, + "unsupported_cgroup_version": { + "title": "Unsupported system - CGroup version", + "description": "System is unsupported because the wrong version of Docker CGroup is in use. Use the link to learn the correct version and how to fix this." + }, + "unsupported_connectivity_check": { + "title": "Unsupported system - Connectivity check disabled", + "description": "System is unsupported because Home Assistant cannot determine when an internet connection is available. Use the link to learn more and how to fix this." + }, + "unsupported_content_trust": { + "title": "Unsupported system - Content-trust check disabled", + "description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. Use the link to learn more and how to fix this." + }, + "unsupported_dbus": { + "title": "Unsupported system - D-Bus issues", + "description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. Use the link to learn more and how to fix this." + }, + "unsupported_dns_server": { + "title": "Unsupported system - DNS server issues", + "description": "System is unsupported because the provided DNS server does not work correctly and the fallback DNS option has been disabled. Use the link to learn more and how to fix this." + }, + "unsupported_docker_configuration": { + "title": "Unsupported system - Docker misconfigured", + "description": "System is unsupported because the Docker daemon is running in an unexpected way. Use the link to learn more and how to fix this." + }, + "unsupported_docker_version": { + "title": "Unsupported system - Docker version", + "description": "System is unsupported because the wrong version of Docker is in use. Use the link to learn the correct version and how to fix this." + }, + "unsupported_job_conditions": { + "title": "Unsupported system - Protections disabled", + "description": "System is unsupported because one or more job conditions have been disabled which protect from unexpected failures and breakages. Use the link to learn more and how to fix this." + }, + "unsupported_lxc": { + "title": "Unsupported system - LXC detected", + "description": "System is unsupported because it is being run in an LXC virtual machine. Use the link to learn more and how to fix this." + }, + "unsupported_network_manager": { + "title": "Unsupported system - Network Manager issues", + "description": "System is unsupported because Network Manager is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + }, + "unsupported_os": { + "title": "Unsupported system - Operating System", + "description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. Use the link to which operating systems are supported and how to fix this." + }, + "unsupported_os_agent": { + "title": "Unsupported system - OS-Agent issues", + "description": "System is unsupported because OS-Agent is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + }, + "unsupported_restart_policy": { + "title": "Unsupported system - Container restart policy", + "description": "System is unsupported because a Docker container has a restart policy set which could cause issues on startup. Use the link to learn more and how to fix this." + }, + "unsupported_software": { + "title": "Unsupported system - Unsupported software", + "description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. Use the link to learn more and how to fix this." + }, + "unsupported_source_mods": { + "title": "Unsupported system - Supervisor source modifications", + "description": "System is unsupported because Supervisor source code has been modified. Use the link to learn more and how to fix this." + }, + "unsupported_supervisor_version": { + "title": "Unsupported system - Supervisor version", + "description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. Use the link to learn more and how to fix this." + }, + "unsupported_systemd": { + "title": "Unsupported system - Systemd issues", + "description": "System is unsupported because Systemd is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + }, + "unsupported_systemd_journal": { + "title": "Unsupported system - Systemd Journal issues", + "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured . Use the link to learn more and how to fix this." + }, + "unsupported_systemd_resolved": { + "title": "Unsupported system - Systemd-Resolved issues", + "description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + } } } diff --git a/homeassistant/components/hassio/translations/ca.json b/homeassistant/components/hassio/translations/ca.json index 2c4285d49084f43a0c569b638b57dec705b1bb25..14679301993a1fd338de525a3d734d83ba8c1c88 100644 --- a/homeassistant/components/hassio/translations/ca.json +++ b/homeassistant/components/hassio/translations/ca.json @@ -1,4 +1,14 @@ { + "issues": { + "unhealthy": { + "description": "El sistema no \u00e9s saludable a causa de '{reason}'. Clica l'enlla\u00e7 per obtenir m\u00e9s informaci\u00f3 sobre qu\u00e8 falla aix\u00f2 i com solucionar-ho.", + "title": "Sistema no saludable - {reason}" + }, + "unsupported": { + "description": "El sistema no \u00e9s compatible a causa de '{reason}'. Clica l'enlla\u00e7 per obtenir m\u00e9s informaci\u00f3 sobre qu\u00e8 significa aix\u00f2 i com tornar a un sistema compatible.", + "title": "Sistema no compatible - {reason}" + } + }, "system_health": { "info": { "agent_version": "Versi\u00f3 de l'agent", diff --git a/homeassistant/components/hassio/translations/en.json b/homeassistant/components/hassio/translations/en.json index 14d79f0d8d6c8cde43b1225142f295dfba713399..243467b9f228af2860bbd4d5b2077aeffbdd9196 100644 --- a/homeassistant/components/hassio/translations/en.json +++ b/homeassistant/components/hassio/translations/en.json @@ -1,4 +1,114 @@ { + "issues": { + "unhealthy": { + "description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this.", + "title": "Unhealthy system - {reason}" + }, + "unhealthy_docker": { + "description": "System is currently unhealthy because Docker is configured incorrectly. Use the link to learn more and how to fix this.", + "title": "Unhealthy system - Docker misconfigured" + }, + "unhealthy_privileged": { + "description": "System is currently unhealthy because it does not have privileged access to the docker runtime. Use the link to learn more and how to fix this.", + "title": "Unhealthy system - Not privileged" + }, + "unhealthy_setup": { + "description": "System is currently because setup failed to complete. There are a number of reasons this can occur, use the link to learn more and how to fix this.", + "title": "Unhealthy system - Setup failed" + }, + "unhealthy_supervisor": { + "description": "System is currently unhealthy because an attempt to update Supervisor to the latest version has failed. Use the link to learn more and how to fix this.", + "title": "Unhealthy system - Supervisor update failed" + }, + "unhealthy_untrusted": { + "description": "System is currently unhealthy because it has detected untrusted code or images in use. Use the link to learn more and how to fix this.", + "title": "Unhealthy system - Untrusted code" + }, + "unsupported": { + "description": "System is unsupported due to {reason}. Use the link to learn more and how to fix this.", + "title": "Unsupported system - {reason}" + }, + "unsupported_apparmor": { + "description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. Use the link to learn more and how to fix this.", + "title": "Unsupported system - AppArmor issues" + }, + "unsupported_cgroup_version": { + "description": "System is unsupported because the wrong version of Docker CGroup is in use. Use the link to learn the correct version and how to fix this.", + "title": "Unsupported system - CGroup version" + }, + "unsupported_connectivity_check": { + "description": "System is unsupported because Home Assistant cannot determine when an internet connection is available. Use the link to learn more and how to fix this.", + "title": "Unsupported system - Connectivity check disabled" + }, + "unsupported_content_trust": { + "description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. Use the link to learn more and how to fix this.", + "title": "Unsupported system - Content-trust check disabled" + }, + "unsupported_dbus": { + "description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. Use the link to learn more and how to fix this.", + "title": "Unsupported system - D-Bus issues" + }, + "unsupported_dns_server": { + "description": "System is unsupported because the provided DNS server does not work correctly and the fallback DNS option has been disabled. Use the link to learn more and how to fix this.", + "title": "Unsupported system - DNS server issues" + }, + "unsupported_docker_configuration": { + "description": "System is unsupported because the Docker daemon is running in an unexpected way. Use the link to learn more and how to fix this.", + "title": "Unsupported system - Docker misconfigured" + }, + "unsupported_docker_version": { + "description": "System is unsupported because the wrong version of Docker is in use. Use the link to learn the correct version and how to fix this.", + "title": "Unsupported system - Docker version" + }, + "unsupported_job_conditions": { + "description": "System is unsupported because one or more job conditions have been disabled which protect from unexpected failures and breakages. Use the link to learn more and how to fix this.", + "title": "Unsupported system - Protections disabled" + }, + "unsupported_lxc": { + "description": "System is unsupported because it is being run in an LXC virtual machine. Use the link to learn more and how to fix this.", + "title": "Unsupported system - LXC detected" + }, + "unsupported_network_manager": { + "description": "System is unsupported because Network Manager is missing, inactive or misconfigured. Use the link to learn more and how to fix this.", + "title": "Unsupported system - Network Manager issues" + }, + "unsupported_os": { + "description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. Use the link to which operating systems are supported and how to fix this.", + "title": "Unsupported system - Operating System" + }, + "unsupported_os_agent": { + "description": "System is unsupported because OS-Agent is missing, inactive or misconfigured. Use the link to learn more and how to fix this.", + "title": "Unsupported system - OS-Agent issues" + }, + "unsupported_restart_policy": { + "description": "System is unsupported because a Docker container has a restart policy set which could cause issues on startup. Use the link to learn more and how to fix this.", + "title": "Unsupported system - Container restart policy" + }, + "unsupported_software": { + "description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. Use the link to learn more and how to fix this.", + "title": "Unsupported system - Unsupported software" + }, + "unsupported_source_mods": { + "description": "System is unsupported because Supervisor source code has been modified. Use the link to learn more and how to fix this.", + "title": "Unsupported system - Supervisor source modifications" + }, + "unsupported_supervisor_version": { + "description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. Use the link to learn more and how to fix this.", + "title": "Unsupported system - Supervisor version" + }, + "unsupported_systemd": { + "description": "System is unsupported because Systemd is missing, inactive or misconfigured. Use the link to learn more and how to fix this.", + "title": "Unsupported system - Systemd issues" + }, + "unsupported_systemd_journal": { + "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured . Use the link to learn more and how to fix this.", + "title": "Unsupported system - Systemd Journal issues" + }, + "unsupported_systemd_resolved": { + "description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this.", + "title": "Unsupported system - Systemd-Resolved issues" + } + }, "system_health": { "info": { "agent_version": "Agent Version", diff --git a/homeassistant/components/hassio/translations/es.json b/homeassistant/components/hassio/translations/es.json index 102256ef1173f3cf2f02451fe0c963e4175c74a7..f2aef9d7214b73095cd785cdf4e34c405944c495 100644 --- a/homeassistant/components/hassio/translations/es.json +++ b/homeassistant/components/hassio/translations/es.json @@ -1,4 +1,14 @@ { + "issues": { + "unhealthy": { + "description": "Actualmente el sistema no est\u00e1 en buen estado debido a ''{reason}''. Utiliza el enlace para obtener m\u00e1s informaci\u00f3n sobre lo que est\u00e1 mal y c\u00f3mo solucionarlo.", + "title": "Sistema en mal estado: {reason}" + }, + "unsupported": { + "description": "El sistema no es compatible debido a ''{reason}''. Utiliza el enlace para obtener m\u00e1s informaci\u00f3n sobre lo que esto significa y c\u00f3mo volver a un sistema compatible.", + "title": "Sistema no compatible: {reason}" + } + }, "system_health": { "info": { "agent_version": "Versi\u00f3n del agente", diff --git a/homeassistant/components/hassio/translations/et.json b/homeassistant/components/hassio/translations/et.json index b86eef353b962b48c547a58f69f06db33c1fd088..ea0f78c0c57c0dd876f9cf16e18fd803a0a84296 100644 --- a/homeassistant/components/hassio/translations/et.json +++ b/homeassistant/components/hassio/translations/et.json @@ -1,4 +1,14 @@ { + "issues": { + "unhealthy": { + "description": "S\u00fcsteem ei ole praegu korras '{reason}' t\u00f5ttu. Kasuta linki, et saada rohkem teavet selle kohta, mis on valesti ja kuidas seda parandada.", + "title": "Vigane s\u00fcsteem \u2013 {reason}" + }, + "unsupported": { + "description": "S\u00fcsteemi ei toetata '{reason}' t\u00f5ttu. Kasuta linki, et saada lisateavet selle kohta, mida see t\u00e4hendab ja kuidas toetatud s\u00fcsteemi naasta.", + "title": "Toetamata s\u00fcsteem \u2013 {reason}" + } + }, "system_health": { "info": { "agent_version": "Agendi versioon", diff --git a/homeassistant/components/hassio/translations/hu.json b/homeassistant/components/hassio/translations/hu.json index 4c83b94935d43bb50459f4e73b34f9c70187672a..604a8ae59e63f137baad77bc9912df4f90bf1ae8 100644 --- a/homeassistant/components/hassio/translations/hu.json +++ b/homeassistant/components/hassio/translations/hu.json @@ -1,4 +1,14 @@ { + "issues": { + "unhealthy": { + "description": "A rendszer jelenleg renellenes \u00e1llapotban van '{reason}' miatt. A link seg\u00edts\u00e9g\u00e9vel t\u00f6bbet is megtudhat arr\u00f3l, hogy mi a probl\u00e9ma, \u00e9s hogyan jav\u00edthatja ki.", + "title": "Rendellenes \u00e1llapot \u2013 {reason}" + }, + "unsupported": { + "description": "A rendszer nem t\u00e1mogatott a k\u00f6vetkez\u0151 miatt: '{reason}'. A hivatkoz\u00e1s seg\u00edts\u00e9g\u00e9vel t\u00f6bbet megtudhat arr\u00f3l, mit jelent ez, \u00e9s hogyan t\u00e9rhet vissza egy t\u00e1mogatott rendszerhez.", + "title": "Nem t\u00e1mogatott rendszer \u2013 {reason}" + } + }, "system_health": { "info": { "agent_version": "\u00dcgyn\u00f6k verzi\u00f3", diff --git a/homeassistant/components/hassio/translations/pt-BR.json b/homeassistant/components/hassio/translations/pt-BR.json index 4f3e5d84ec1699e4a17f7d99535d4360d1993ec8..47e0b6df4aed6b1de6b6cd2ecb52e7d8fa393157 100644 --- a/homeassistant/components/hassio/translations/pt-BR.json +++ b/homeassistant/components/hassio/translations/pt-BR.json @@ -1,4 +1,14 @@ { + "issues": { + "unhealthy": { + "description": "O sistema n\u00e3o est\u00e1 \u00edntegro devido a '{reason}'. Use o link para saber mais sobre o que est\u00e1 errado e como corrigi-lo.", + "title": "Sistema insalubre - {reason}" + }, + "unsupported": { + "description": "O sistema n\u00e3o \u00e9 suportado devido a '{reason}'. Use o link para saber mais sobre o que isso significa e como retornar a um sistema compat\u00edvel.", + "title": "Sistema n\u00e3o suportado - {reason}" + } + }, "system_health": { "info": { "agent_version": "Vers\u00e3o do Agent", diff --git a/homeassistant/components/hassio/translations/ru.json b/homeassistant/components/hassio/translations/ru.json index 5e1caa41ebf56cf59dc2c86d7776d628f71d85ab..0ab366c1775ef0f5fa5164025482543aa730dc37 100644 --- a/homeassistant/components/hassio/translations/ru.json +++ b/homeassistant/components/hassio/translations/ru.json @@ -1,4 +1,14 @@ { + "issues": { + "unhealthy": { + "description": "\u0421\u0438\u0441\u0442\u0435\u043c\u0430 \u0432 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0435\u0435 \u0432\u0440\u0435\u043c\u044f \u043d\u0435\u0440\u0430\u0431\u043e\u0442\u043e\u0441\u043f\u043e\u0441\u043e\u0431\u043d\u0430 \u043f\u043e \u043f\u0440\u0438\u0447\u0438\u043d\u0435 '{reason}'. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435, \u0447\u0442\u043e\u0431\u044b \u0443\u0437\u043d\u0430\u0442\u044c \u0447\u0442\u043e \u043d\u0435 \u0442\u0430\u043a \u0438 \u043a\u0430\u043a \u044d\u0442\u043e \u0438\u0441\u043f\u0440\u0430\u0432\u0438\u0442\u044c.", + "title": "\u041d\u0435\u0440\u0430\u0431\u043e\u0442\u043e\u0441\u043f\u043e\u0441\u043e\u0431\u043d\u0430\u044f \u0441\u0438\u0441\u0442\u0435\u043c\u0430 - {reason}" + }, + "unsupported": { + "description": "\u0421\u0438\u0441\u0442\u0435\u043c\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f \u043f\u043e \u043f\u0440\u0438\u0447\u0438\u043d\u0435 '{reason}'. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435, \u0447\u0442\u043e\u0431\u044b \u0443\u0437\u043d\u0430\u0442\u044c \u0447\u0442\u043e \u044d\u0442\u043e \u0437\u043d\u0430\u0447\u0438\u0442 \u0438 \u043a\u0430\u043a \u0432\u0435\u0440\u043d\u0443\u0442\u044c\u0441\u044f \u043a \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435.", + "title": "\u041d\u0435\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u0430\u044f \u0441\u0438\u0441\u0442\u0435\u043c\u0430 - {reason}" + } + }, "system_health": { "info": { "agent_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u0430\u0433\u0435\u043d\u0442\u0430", diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index eb0d6c5407700eb07de2f6ea5f08199c18eccb70..3670d5ca1fd9bfaa8c9c282315c3dead4ce9fb18 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -2,6 +2,7 @@ import logging from numbers import Number import re +from typing import Any import voluptuous as vol @@ -60,7 +61,7 @@ def async_load_websocket_api(hass: HomeAssistant): @websocket_api.websocket_command({vol.Required(WS_TYPE): WS_TYPE_SUBSCRIBE}) @websocket_api.async_response async def websocket_subscribe( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ): """Subscribe to supervisor events.""" @@ -83,7 +84,7 @@ async def websocket_subscribe( ) @websocket_api.async_response async def websocket_supervisor_event( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ): """Publish events from the Supervisor.""" connection.send_result(msg[WS_ID]) @@ -101,7 +102,7 @@ async def websocket_supervisor_event( ) @websocket_api.async_response async def websocket_supervisor_api( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ): """Websocket handler to call Supervisor API.""" if not connection.user.is_admin and not WS_NO_ADMIN_ENDPOINTS.match( diff --git a/homeassistant/components/havana_shade/manifest.json b/homeassistant/components/havana_shade/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..e9499712bf769c26226374ac96a3f1caa00e8ea8 --- /dev/null +++ b/homeassistant/components/havana_shade/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "havana_shade", + "name": "Havana Shade", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py index 400c280263a07e5051f92ce4fa47af10485f2b58..199035b27132c9f683b49fc0921b601d52eba75e 100644 --- a/homeassistant/components/haveibeenpwned/sensor.py +++ b/homeassistant/components/haveibeenpwned/sensor.py @@ -10,7 +10,7 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_EMAIL +from homeassistant.const import CONF_API_KEY, CONF_EMAIL from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -21,8 +21,6 @@ import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by Have I Been Pwned (HIBP)" - DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" HA_USER_AGENT = "Home Assistant HaveIBeenPwned Sensor Component" @@ -61,6 +59,8 @@ def setup_platform( class HaveIBeenPwnedSensor(SensorEntity): """Implementation of a HaveIBeenPwned sensor.""" + _attr_attribution = "Data provided by Have I Been Pwned (HIBP)" + def __init__(self, data, email): """Initialize the HaveIBeenPwned sensor.""" self._state = None @@ -86,7 +86,7 @@ class HaveIBeenPwnedSensor(SensorEntity): @property def extra_state_attributes(self): """Return the attributes of the sensor.""" - val = {ATTR_ATTRIBUTION: ATTRIBUTION} + val = {} if self._email not in self._data.data: return val diff --git a/homeassistant/components/heiwa/manifest.json b/homeassistant/components/heiwa/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..950578a82c0f46dea95f713e397ffaf7103f9b7e --- /dev/null +++ b/homeassistant/components/heiwa/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "heiwa", + "name": "Heiwa", + "integration_type": "virtual", + "supported_by": "gree" +} diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 549cc08356c3e99bb363e345c88ff06efa63f298..b9ffa3e4baa761f49dbe8479ed17713ca9d118e8 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -14,8 +14,8 @@ from homeassistant.const import ( CONF_API_KEY, CONF_MODE, CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_METERS, + LENGTH_MILES, Platform, ) from homeassistant.core import HomeAssistant @@ -23,7 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_conversion import DistanceConverter from .const import ( ATTR_DESTINATION, @@ -33,7 +33,6 @@ from .const import ( ATTR_DURATION_IN_TRAFFIC, ATTR_ORIGIN, ATTR_ORIGIN_NAME, - ATTR_ROUTE, CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, CONF_DESTINATION_ENTITY_ID, @@ -45,6 +44,7 @@ from .const import ( CONF_ROUTE_MODE, DEFAULT_SCAN_INTERVAL, DOMAIN, + IMPERIAL_UNITS, NO_ROUTE_ERROR_MESSAGE, TRAFFIC_MODE_ENABLED, TRAVEL_MODES_VEHICLE, @@ -178,9 +178,11 @@ class HereTravelTimeDataUpdateCoordinator(DataUpdateCoordinator): traffic_time: float = summary["baseTime"] if self.config.travel_mode in TRAVEL_MODES_VEHICLE: traffic_time = summary["trafficTime"] - if self.config.units == CONF_UNIT_SYSTEM_IMPERIAL: + if self.config.units == IMPERIAL_UNITS: # Convert to miles. - distance = IMPERIAL_SYSTEM.length(distance, LENGTH_METERS) + distance = DistanceConverter.convert( + distance, LENGTH_METERS, LENGTH_MILES + ) else: # Convert to kilometers distance = distance / 1000 @@ -190,7 +192,6 @@ class HereTravelTimeDataUpdateCoordinator(DataUpdateCoordinator): ATTR_DURATION: round(summary["baseTime"] / 60), # type: ignore[misc] ATTR_DURATION_IN_TRAFFIC: round(traffic_time / 60), ATTR_DISTANCE: distance, - ATTR_ROUTE: response.route_short, ATTR_ORIGIN: ",".join(origin), ATTR_DESTINATION: ",".join(destination), ATTR_ORIGIN_NAME: waypoint[0]["mappedRoadName"], diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index 09faf95177d016bebf0417557295983ff35cffd0..38bd1742c9179d89a60dd4419a2e028b9bb0a673 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.helpers.selector import ( LocationSelector, TimeSelector, ) +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( CONF_ARRIVAL_TIME, @@ -40,6 +41,8 @@ from .const import ( CONF_TRAFFIC_MODE, DEFAULT_NAME, DOMAIN, + IMPERIAL_UNITS, + METRIC_UNITS, ROUTE_MODE_FASTEST, ROUTE_MODES, TRAFFIC_MODE_ENABLED, @@ -88,13 +91,16 @@ def get_user_step_schema(data: dict[str, Any]) -> vol.Schema: def default_options(hass: HomeAssistant) -> dict[str, str | None]: """Get the default options.""" - return { + default = { CONF_TRAFFIC_MODE: TRAFFIC_MODE_ENABLED, CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: None, CONF_DEPARTURE_TIME: None, - CONF_UNIT_SYSTEM: hass.config.units.name, + CONF_UNIT_SYSTEM: METRIC_UNITS, } + if hass.config.units is US_CUSTOMARY_SYSTEM: + default[CONF_UNIT_SYSTEM] = IMPERIAL_UNITS + return default class HERETravelTimeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -255,24 +261,25 @@ class HERETravelTimeOptionsFlow(config_entries.OptionsFlow): menu_options=["departure_time", "no_time"], ) + defaults = default_options(self.hass) schema = vol.Schema( { vol.Optional( CONF_TRAFFIC_MODE, default=self.config_entry.options.get( - CONF_TRAFFIC_MODE, TRAFFIC_MODE_ENABLED + CONF_TRAFFIC_MODE, defaults[CONF_TRAFFIC_MODE] ), ): vol.In(TRAFFIC_MODES), vol.Optional( CONF_ROUTE_MODE, default=self.config_entry.options.get( - CONF_ROUTE_MODE, ROUTE_MODE_FASTEST + CONF_ROUTE_MODE, defaults[CONF_ROUTE_MODE] ), ): vol.In(ROUTE_MODES), vol.Optional( CONF_UNIT_SYSTEM, default=self.config_entry.options.get( - CONF_UNIT_SYSTEM, self.hass.config.units.name + CONF_UNIT_SYSTEM, defaults[CONF_UNIT_SYSTEM] ), ): vol.In(UNITS), } diff --git a/homeassistant/components/here_travel_time/const.py b/homeassistant/components/here_travel_time/const.py index 4e9b8beaf12549617f3c0f539660bc95b6a47c63..ea0dc5c136ea9702f893a6caa9443fe5a454d943 100644 --- a/homeassistant/components/here_travel_time/const.py +++ b/homeassistant/components/here_travel_time/const.py @@ -1,10 +1,4 @@ """Constants for the HERE Travel Time integration.""" -from homeassistant.const import ( - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, -) - DOMAIN = "here_travel_time" DEFAULT_SCAN_INTERVAL = 300 @@ -65,15 +59,16 @@ ICONS = { TRAVEL_MODE_TRUCK: ICON_TRUCK, } -UNITS = [CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL] +IMPERIAL_UNITS = "imperial" +METRIC_UNITS = "metric" +UNITS = [METRIC_UNITS, IMPERIAL_UNITS] ATTR_DURATION = "duration" ATTR_DISTANCE = "distance" -ATTR_ROUTE = "route" ATTR_ORIGIN = "origin" ATTR_DESTINATION = "destination" -ATTR_UNIT_SYSTEM = CONF_UNIT_SYSTEM +ATTR_UNIT_SYSTEM = "unit_system" ATTR_TRAFFIC_MODE = CONF_TRAFFIC_MODE ATTR_DURATION_IN_TRAFFIC = "duration_in_traffic" diff --git a/homeassistant/components/here_travel_time/model.py b/homeassistant/components/here_travel_time/model.py index 65673a1e8b6863535c903f908095feaa9371f093..7310ac24e77eea168f937b61c27a4e02978ef79a 100644 --- a/homeassistant/components/here_travel_time/model.py +++ b/homeassistant/components/here_travel_time/model.py @@ -13,7 +13,6 @@ class HERERoutingData(TypedDict): ATTR_DURATION: float ATTR_DURATION_IN_TRAFFIC: float ATTR_DISTANCE: float - ATTR_ROUTE: str ATTR_ORIGIN: str ATTR_DESTINATION: str ATTR_ORIGIN_NAME: str diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 74a9ae357e11b2c1d92cd897b8852cec51e4f53c..1ee0087eab7fba4b19d51178cfbcd95ddf744ed3 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import ( ATTR_LONGITUDE, CONF_MODE, CONF_NAME, - CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_KILOMETERS, LENGTH_MILES, TIME_MINUTES, @@ -38,10 +37,10 @@ from .const import ( ATTR_DURATION_IN_TRAFFIC, ATTR_ORIGIN, ATTR_ORIGIN_NAME, - ATTR_ROUTE, DOMAIN, ICON_CAR, ICONS, + IMPERIAL_UNITS, ) SCAN_INTERVAL = timedelta(minutes=5) @@ -58,17 +57,12 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...] native_unit_of_measurement=TIME_MINUTES, ), SensorEntityDescription( - name="Duration in Traffic", + name="Duration in traffic", icon=ICONS.get(travel_mode, ICON_CAR), key=ATTR_DURATION_IN_TRAFFIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=TIME_MINUTES, ), - SensorEntityDescription( - name="Route", - icon="mdi:directions", - key=ATTR_ROUTE, - ), ) @@ -112,7 +106,6 @@ class HERETravelTimeSensor(SensorEntity, CoordinatorEntity): """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = sensor_description - self._attr_name = f"{name} {sensor_description.name}" self._attr_unique_id = f"{unique_id_prefix}_{sensor_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id_prefix)}, @@ -120,6 +113,7 @@ class HERETravelTimeSensor(SensorEntity, CoordinatorEntity): name=name, manufacturer="HERE Technologies", ) + self._attr_has_entity_name = True async def async_added_to_hass(self) -> None: """Wait for start so origin and destination entities can be resolved.""" @@ -222,6 +216,6 @@ class DistanceSensor(HERETravelTimeSensor): @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor.""" - if self.coordinator.config.units == CONF_UNIT_SYSTEM_IMPERIAL: + if self.coordinator.config.units == IMPERIAL_UNITS: return LENGTH_MILES return LENGTH_KILOMETERS diff --git a/homeassistant/components/here_travel_time/translations/he.json b/homeassistant/components/here_travel_time/translations/he.json index dc5eb786f67b00bf6b4e61d7e2af9d723cc00745..5ddb6737e2ad2248cb5c85c3f431ddb57c9301ac 100644 --- a/homeassistant/components/here_travel_time/translations/he.json +++ b/homeassistant/components/here_travel_time/translations/he.json @@ -10,7 +10,8 @@ "step": { "user": { "data": { - "api_key": "\u05de\u05e4\u05ea\u05d7 API" + "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "name": "\u05e9\u05dd" } } } diff --git a/homeassistant/components/here_travel_time/translations/nb.json b/homeassistant/components/here_travel_time/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/here_travel_time/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hi_kumo/manifest.json b/homeassistant/components/hi_kumo/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..6b976b4574fa802a235098c0c4c59aec6f77adaa --- /dev/null +++ b/homeassistant/components/hi_kumo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "hi_kumo", + "name": "Hitachi Hi Kumo", + "integration_type": "virtual", + "supported_by": "overkiz" +} diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index bae6c95507eb4e66d73f23f3c05a9565b0d31b54..165ef72d5254fe46072d83d6c58dea3b0010c261 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -6,7 +6,7 @@ from datetime import datetime as dt, timedelta from http import HTTPStatus import logging import time -from typing import cast +from typing import Any, cast from aiohttp import web import voluptuous as vol @@ -79,7 +79,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) @websocket_api.async_response async def ws_get_statistics_during_period( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle statistics websocket command.""" _LOGGER.warning( @@ -97,7 +97,7 @@ async def ws_get_statistics_during_period( ) @websocket_api.async_response async def ws_get_list_statistic_ids( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Fetch a list of available statistic_id.""" _LOGGER.warning( @@ -164,7 +164,7 @@ def _ws_get_significant_states( ) @websocket_api.async_response async def ws_get_history_during_period( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle history during period websocket command.""" start_time_str = msg["start_time"] diff --git a/homeassistant/components/hive/translations/bg.json b/homeassistant/components/hive/translations/bg.json index 082fb940fcad6d32f90bc1404656560b89e1f27c..c027da47da53c6706f76dce42305e1e8259f8167 100644 --- a/homeassistant/components/hive/translations/bg.json +++ b/homeassistant/components/hive/translations/bg.json @@ -1,9 +1,17 @@ { "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": { "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": { + "configuration": { + "data": { + "device_name": "\u0418\u043c\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e" + } + }, "reauth": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430" diff --git a/homeassistant/components/hive/translations/nb.json b/homeassistant/components/hive/translations/nb.json index a9f534742c5175849a26c03cc0afc9c0b7aa8d45..0baf44898bf4aaec5366fd0b78951fc52d6dae9c 100644 --- a/homeassistant/components/hive/translations/nb.json +++ b/homeassistant/components/hive/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "reauth": { "data": { diff --git a/homeassistant/components/hive/translations/no.json b/homeassistant/components/hive/translations/no.json index 17241b940c458fb8754c145688f35e0073dbf322..120f50733af2b3a30c3f8d1d1d75d1b72e05ef7d 100644 --- a/homeassistant/components/hive/translations/no.json +++ b/homeassistant/components/hive/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "unknown_entry": "Kunne ikke finne eksisterende oppf\u00f8ring." }, "error": { diff --git a/homeassistant/components/hlk_sw16/translations/nb.json b/homeassistant/components/hlk_sw16/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/home_plus_control/translations/bg.json b/homeassistant/components/home_plus_control/translations/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..e62469db0ecdb4c57330356d7dbe66448ba92edb --- /dev/null +++ b/homeassistant/components/home_plus_control/translations/bg.json @@ -0,0 +1,17 @@ +{ + "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", + "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.", + "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 \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "step": { + "pick_implementation": { + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u043c\u0435\u0442\u043e\u0434 \u043d\u0430 \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant_alerts/manifest.json b/homeassistant/components/homeassistant_alerts/manifest.json index 20e6447dadf5c0db460807ab2e6e85406d1b6680..d6f6d9ab6147910e8839630016598e10c21e76a3 100644 --- a/homeassistant/components/homeassistant_alerts/manifest.json +++ b/homeassistant/components/homeassistant_alerts/manifest.json @@ -4,5 +4,6 @@ "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/homeassistant_alerts", "codeowners": ["@home-assistant/core"], - "quality_scale": "internal" + "quality_scale": "internal", + "integration_type": "system" } diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 4859d9e106567790e57340c8112c6216d5e97cbf..b809f6db205a402ad45e6dcd6352a029661d9876 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -74,7 +74,13 @@ from . import ( # noqa: F401 type_switches, type_thermostats, ) -from .accessories import HomeAccessory, HomeBridge, HomeDriver, get_accessory +from .accessories import ( + HomeAccessory, + HomeBridge, + HomeDriver, + HomeIIDManager, + get_accessory, +) from .aidmanager import AccessoryAidStorage from .const import ( ATTR_INTEGRATION, @@ -107,6 +113,7 @@ from .const import ( SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, ) +from .iidmanager import AccessoryIIDStorage from .type_triggers import DeviceTriggerAccessory from .util import ( accessory_friendly_name, @@ -489,8 +496,6 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None: class HomeKit: """Class to handle all actions between HomeKit and Home Assistant.""" - driver: HomeDriver - def __init__( self, hass: HomeAssistant, @@ -504,7 +509,7 @@ class HomeKit: advertise_ip: str | None, entry_id: str, entry_title: str, - devices: Iterable[str] | None = None, + devices: list[str] | None = None, ) -> None: """Initialize a HomeKit object.""" self.hass = hass @@ -520,12 +525,14 @@ class HomeKit: self._homekit_mode = homekit_mode self._devices = devices or [] self.aid_storage: AccessoryAidStorage | None = None + self.iid_storage: AccessoryIIDStorage | None = None self.status = STATUS_READY - + self.driver: HomeDriver | None = None self.bridge: HomeBridge | None = None def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> None: """Set up bridge and accessory driver.""" + assert self.iid_storage is not None persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id) self.driver = HomeDriver( @@ -541,6 +548,7 @@ class HomeKit: async_zeroconf_instance=async_zeroconf_instance, zeroconf_server=f"{uuid}-hap.local.", loader=get_loader(), + iid_manager=HomeIIDManager(self.iid_storage), ) # If we do not load the mac address will be wrong @@ -555,14 +563,27 @@ class HomeKit: return await self.async_reset_accessories_in_bridge_mode(entity_ids) + async def _async_shutdown_accessory(self, accessory: HomeAccessory) -> None: + """Shutdown an accessory.""" + assert self.driver is not None + await accessory.stop() + # Deallocate the IIDs for the accessory + iid_manager = self.driver.iid_manager + for service in accessory.services: + iid_manager.remove_iid(iid_manager.remove_obj(service)) + for char in service.characteristics: + iid_manager.remove_iid(iid_manager.remove_obj(char)) + async def async_reset_accessories_in_accessory_mode( self, entity_ids: Iterable[str] ) -> None: """Reset accessories in accessory mode.""" + assert self.driver is not None + acc = cast(HomeAccessory, self.driver.accessory) + await self._async_shutdown_accessory(acc) if acc.entity_id not in entity_ids: return - await acc.stop() if not (state := self.hass.states.get(acc.entity_id)): _LOGGER.warning( "The underlying entity %s disappeared during reset", acc.entity_id @@ -579,6 +600,8 @@ class HomeKit: """Reset accessories in bridge mode.""" assert self.aid_storage is not None assert self.bridge is not None + assert self.driver is not None + new = [] acc: HomeAccessory | None for entity_id in entity_ids: @@ -590,9 +613,10 @@ class HomeKit: self._name, entity_id, ) - if (acc := await self.async_remove_bridge_accessory(aid)) and ( - state := self.hass.states.get(acc.entity_id) - ): + acc = await self.async_remove_bridge_accessory(aid) + if acc: + await self._async_shutdown_accessory(acc) + if acc and (state := self.hass.states.get(acc.entity_id)): new.append(state) else: _LOGGER.warning( @@ -612,10 +636,13 @@ class HomeKit: async def async_config_changed(self) -> None: """Call config changed which writes out the new config to disk.""" + assert self.driver is not None await self.hass.async_add_executor_job(self.driver.config_changed) def add_bridge_accessory(self, state: State) -> HomeAccessory | None: """Try adding accessory to bridge if configured beforehand.""" + assert self.driver is not None + if self._would_exceed_max_devices(state.entity_id): return None @@ -694,7 +721,6 @@ class HomeKit: """Try adding accessory to bridge if configured beforehand.""" assert self.bridge is not None if acc := self.bridge.accessories.pop(aid, None): - await acc.stop() return cast(HomeAccessory, acc) return None @@ -741,9 +767,14 @@ class HomeKit: self.status = STATUS_WAIT async_zc_instance = await zeroconf.async_get_async_instance(self.hass) uuid = await instance_id.async_get(self.hass) - await self.hass.async_add_executor_job(self.setup, async_zc_instance, uuid) self.aid_storage = AccessoryAidStorage(self.hass, self._entry_id) + self.iid_storage = AccessoryIIDStorage(self.hass, self._entry_id) + # Avoid gather here since it will be I/O bound anyways await self.aid_storage.async_initialize() + await self.iid_storage.async_initialize() + await self.hass.async_add_executor_job(self.setup, async_zc_instance, uuid) + assert self.driver is not None + if not await self._async_create_accessories(): return self._async_register_bridge() @@ -760,6 +791,8 @@ class HomeKit: @callback def _async_show_setup_message(self) -> None: """Show the pairing setup message.""" + assert self.driver is not None + async_show_setup_message( self.hass, self._entry_id, @@ -771,6 +804,8 @@ class HomeKit: @callback def async_unpair(self) -> None: """Remove all pairings for an accessory so it can be repaired.""" + assert self.driver is not None + state = self.driver.state for client_uuid in list(state.paired_clients): # We need to check again since removing a single client @@ -842,6 +877,8 @@ class HomeKit: self, entity_states: list[State] ) -> HomeAccessory | None: """Create a single HomeKit accessory (accessory mode).""" + assert self.driver is not None + if not entity_states: _LOGGER.error( "HomeKit %s cannot startup: entity not available: %s", @@ -864,6 +901,8 @@ class HomeKit: self, entity_states: Iterable[State] ) -> HomeAccessory: """Create a HomeKit bridge with accessories. (bridge mode).""" + assert self.driver is not None + self.bridge = HomeBridge(self.hass, self.driver, self._name) for state in entity_states: self.add_bridge_accessory(state) @@ -892,6 +931,8 @@ class HomeKit: async def _async_create_accessories(self) -> bool: """Create the accessories.""" + assert self.driver is not None + entity_states = await self.async_configure_accessories() if self._homekit_mode == HOMEKIT_MODE_ACCESSORY: acc = self._async_create_single_accessory(entity_states) @@ -910,7 +951,8 @@ class HomeKit: return self.status = STATUS_STOPPED _LOGGER.debug("Driver stop for %s", self._name) - await self.driver.async_stop() + if self.driver: + await self.driver.async_stop() @callback def _async_configure_linked_sensors( diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 9d428573b5b2bebafc723054c0d5260ed66ff5aa..61c2e3cd5ddd63c4f64797c63dc18200946b98c1 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -7,7 +7,10 @@ from uuid import UUID from pyhap.accessory import Accessory, Bridge from pyhap.accessory_driver import AccessoryDriver +from pyhap.characteristic import Characteristic from pyhap.const import CATEGORY_OTHER +from pyhap.iid_manager import IIDManager +from pyhap.service import Service from pyhap.util import callback as pyhap_callback from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature @@ -83,6 +86,7 @@ from .const import ( TYPE_SWITCH, TYPE_VALVE, ) +from .iidmanager import AccessoryIIDStorage from .util import ( accessory_friendly_name, async_dismiss_setup_message, @@ -266,6 +270,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] driver=driver, display_name=cleanup_name_for_homekit(name), aid=aid, + iid_manager=driver.iid_manager, *args, **kwargs, ) @@ -316,8 +321,8 @@ class HomeAccessory(Accessory): # type: ignore[misc] serv_info.configure_char( CHAR_HARDWARE_REVISION, value=hw_version[:MAX_VERSION_LENGTH] ) - self.iid_manager.assign(char) char.broker = self + self.iid_manager.assign(char) self.category = category self.entity_id = entity_id @@ -565,7 +570,7 @@ class HomeBridge(Bridge): # type: ignore[misc] def __init__(self, hass: HomeAssistant, driver: HomeDriver, name: str) -> None: """Initialize a Bridge object.""" - super().__init__(driver, name) + super().__init__(driver, name, iid_manager=driver.iid_manager) self.set_info_service( firmware_revision=format_version(__version__), manufacturer=MANUFACTURER, @@ -598,6 +603,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] entry_id: str, bridge_name: str, entry_title: str, + iid_manager: HomeIIDManager, **kwargs: Any, ) -> None: """Initialize a AccessoryDriver object.""" @@ -606,6 +612,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] self._entry_id = entry_id self._bridge_name = bridge_name self._entry_title = entry_title + self.iid_manager = iid_manager @pyhap_callback # type: ignore[misc] def pair( @@ -632,3 +639,30 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] self.state.pincode, self.accessory.xhm_uri(), ) + + +class HomeIIDManager(IIDManager): # type: ignore[misc] + """IID Manager that remembers IIDs between restarts.""" + + def __init__(self, iid_storage: AccessoryIIDStorage) -> None: + """Initialize a IIDManager object.""" + super().__init__() + self._iid_storage = iid_storage + + def get_iid_for_obj(self, obj: Characteristic | Service) -> int: + """Get IID for object.""" + aid = obj.broker.aid + if isinstance(obj, Characteristic): + service = obj.service + iid = self._iid_storage.get_or_allocate_iid( + aid, service.type_id, service.unique_id, obj.type_id, obj.unique_id + ) + else: + iid = self._iid_storage.get_or_allocate_iid( + aid, obj.type_id, obj.unique_id, None, None + ) + if iid in self.objs: + raise RuntimeError( + f"Cannot assign IID {iid} to {obj} as it is already in use by: {self.objs[iid]}" + ) + return iid diff --git a/homeassistant/components/homekit/diagnostics.py b/homeassistant/components/homekit/diagnostics.py index f717f02ea026ba8bb72ef7721a359f8e060f9f1e..dbd40c1d6f50da70c5acef83150893670414ce8b 100644 --- a/homeassistant/components/homekit/diagnostics.py +++ b/homeassistant/components/homekit/diagnostics.py @@ -6,12 +6,16 @@ from typing import Any from pyhap.accessory_driver import AccessoryDriver from pyhap.state import State +from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from . import HomeKit +from .accessories import HomeAccessory, HomeBridge from .const import DOMAIN, HOMEKIT +TO_REDACT = {"access_token", "entity_picture"} + async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry @@ -27,9 +31,14 @@ async def async_get_config_entry_diagnostics( "options": dict(entry.options), }, } - if not hasattr(homekit, "driver"): + if not homekit.driver: # not started yet or startup failed return data driver: AccessoryDriver = homekit.driver + if driver.accessory: + if isinstance(driver.accessory, HomeBridge): + data["bridge"] = _get_bridge_diagnostics(hass, driver.accessory) + else: + data["accessory"] = _get_accessory_diagnostics(hass, driver.accessory) data.update(driver.get_accessories()) state: State = driver.state data.update( @@ -42,3 +51,27 @@ async def async_get_config_entry_diagnostics( } ) return data + + +def _get_bridge_diagnostics(hass: HomeAssistant, bridge: HomeBridge) -> dict[int, Any]: + """Return diagnostics for a bridge.""" + return { + aid: _get_accessory_diagnostics(hass, accessory) + for aid, accessory in bridge.accessories.items() + } + + +def _get_accessory_diagnostics( + hass: HomeAssistant, accessory: HomeAccessory +) -> dict[str, Any]: + """Return diagnostics for an accessory.""" + return { + "aid": accessory.aid, + "config": accessory.config, + "category": accessory.category, + "name": accessory.display_name, + "entity_id": accessory.entity_id, + "entity_state": async_redact_data( + hass.states.get(accessory.entity_id), TO_REDACT + ), + } diff --git a/homeassistant/components/homekit/iidmanager.py b/homeassistant/components/homekit/iidmanager.py new file mode 100644 index 0000000000000000000000000000000000000000..1b5cc7d67229526c334fd51673eef228ca269119 --- /dev/null +++ b/homeassistant/components/homekit/iidmanager.py @@ -0,0 +1,96 @@ +""" +Manage allocation of instance ID's. + +HomeKit needs to allocate unique numbers to each accessory. These need to +be stable between reboots and upgrades. + +This module generates and stores them in a HA storage. +""" +from __future__ import annotations + +from uuid import UUID + +from pyhap.util import uuid_to_hap_type + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.storage import Store + +from .util import get_iid_storage_filename_for_entry_id + +IID_MANAGER_STORAGE_VERSION = 1 +IID_MANAGER_SAVE_DELAY = 2 + +ALLOCATIONS_KEY = "allocations" + +IID_MIN = 1 +IID_MAX = 18446744073709551615 + + +class AccessoryIIDStorage: + """ + Provide stable allocation of IIDs for the lifetime of an accessory. + + Will generate new ID's, ensure they are unique and store them to make sure they + persist over reboots. + """ + + def __init__(self, hass: HomeAssistant, entry_id: str) -> None: + """Create a new iid store.""" + self.hass = hass + self.allocations: dict[str, int] = {} + self.allocated_iids: list[int] = [] + self.entry_id = entry_id + self.store: Store | None = None + + async def async_initialize(self) -> None: + """Load the latest IID data.""" + iid_store = get_iid_storage_filename_for_entry_id(self.entry_id) + self.store = Store(self.hass, IID_MANAGER_STORAGE_VERSION, iid_store) + + if not (raw_storage := await self.store.async_load()): + # There is no data about iid allocations yet + return + + assert isinstance(raw_storage, dict) + self.allocations = raw_storage.get(ALLOCATIONS_KEY, {}) + self.allocated_iids = sorted(self.allocations.values()) + + def get_or_allocate_iid( + self, + aid: int, + service_uuid: UUID, + service_unique_id: str | None, + char_uuid: UUID | None, + char_unique_id: str | None, + ) -> int: + """Generate a stable iid.""" + service_hap_type: str = uuid_to_hap_type(service_uuid) + char_hap_type: str | None = uuid_to_hap_type(char_uuid) if char_uuid else None + # Allocation key must be a string since we are saving it to JSON + allocation_key = ( + f'{aid}_{service_hap_type}_{service_unique_id or ""}_' + f'{char_hap_type or ""}_{char_unique_id or ""}' + ) + if allocation_key in self.allocations: + return self.allocations[allocation_key] + next_iid = self.allocated_iids[-1] + 1 if self.allocated_iids else 1 + self.allocations[allocation_key] = next_iid + self.allocated_iids.append(next_iid) + self._async_schedule_save() + return next_iid + + @callback + def _async_schedule_save(self) -> None: + """Schedule saving the iid allocations.""" + assert self.store is not None + self.store.async_delay_save(self._data_to_save, IID_MANAGER_SAVE_DELAY) + + async def async_save(self) -> None: + """Save the iid allocations.""" + assert self.store is not None + return await self.store.async_save(self._data_to_save()) + + @callback + def _data_to_save(self) -> dict[str, dict[str, int]]: + """Return data of entity map to store in a file.""" + return {ALLOCATIONS_KEY: self.allocations} diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index ecc73f5e731f881c391c123f2d102bdd4c491e40..e3116c99e26c38e4b575b64aeadea5146d7247ba 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -105,7 +105,9 @@ class Fan(HomeAccessory): ) elif self.preset_modes: for preset_mode in self.preset_modes: - preset_serv = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + preset_serv = self.add_preload_service( + SERV_SWITCH, CHAR_NAME, unique_id=preset_mode + ) serv_fan.add_linked_service(preset_serv) preset_serv.configure_char( CHAR_NAME, diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 4b433a5dc3a8bffcc3ac48ba561a11c05b9740ee..65c0368f6e6cd2e671e8056fc1c7f42dd4c17707 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -import math from pyhap.const import CATEGORY_LIGHTBULB @@ -10,10 +9,10 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, @@ -33,6 +32,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.event import async_call_later from homeassistant.util.color import ( + color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin, color_temperature_to_hs, color_temperature_to_rgbww, @@ -55,8 +55,8 @@ _LOGGER = logging.getLogger(__name__) CHANGE_COALESCE_TIME_WINDOW = 0.01 -DEFAULT_MIN_MIREDS = 153 -DEFAULT_MAX_MIREDS = 500 +DEFAULT_MIN_COLOR_TEMP = 2000 # 500 mireds +DEFAULT_MAX_COLOR_TEMP = 6500 # 153 mireds COLOR_MODES_WITH_WHITES = {ColorMode.RGBW, ColorMode.RGBWW, ColorMode.WHITE} @@ -110,11 +110,11 @@ class Light(HomeAccessory): self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) if CHAR_COLOR_TEMPERATURE in self.chars: - self.min_mireds = math.floor( - attributes.get(ATTR_MIN_MIREDS, DEFAULT_MIN_MIREDS) + self.min_mireds = color_temperature_kelvin_to_mired( + attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN, DEFAULT_MAX_COLOR_TEMP) ) - self.max_mireds = math.ceil( - attributes.get(ATTR_MAX_MIREDS, DEFAULT_MAX_MIREDS) + self.max_mireds = color_temperature_kelvin_to_mired( + attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN, DEFAULT_MIN_COLOR_TEMP) ) if not self.color_temp_supported and not self.rgbww_supported: self.max_mireds = self.min_mireds @@ -190,10 +190,13 @@ class Light(HomeAccessory): ((brightness_pct or self.char_brightness.value) * 255) / 100 ) if self.color_temp_supported: - params[ATTR_COLOR_TEMP] = temp + params[ATTR_COLOR_TEMP_KELVIN] = color_temperature_mired_to_kelvin(temp) elif self.rgbww_supported: params[ATTR_RGBWW_COLOR] = color_temperature_to_rgbww( - temp, bright_val, self.min_mireds, self.max_mireds + color_temperature_mired_to_kelvin(temp), + bright_val, + color_temperature_mired_to_kelvin(self.max_mireds), + color_temperature_mired_to_kelvin(self.min_mireds), ) elif self.rgbw_supported: params[ATTR_RGBW_COLOR] = (*(0,) * 3, bright_val) @@ -258,10 +261,8 @@ class Light(HomeAccessory): # Handle Color - color must always be set before color temperature # or the iOS UI will not display it correctly. if self.color_supported: - if color_temp := attributes.get(ATTR_COLOR_TEMP): - hue, saturation = color_temperature_to_hs( - color_temperature_mired_to_kelvin(color_temp) - ) + if color_temp := attributes.get(ATTR_COLOR_TEMP_KELVIN): + hue, saturation = color_temperature_to_hs(color_temp) elif color_mode == ColorMode.WHITE: hue, saturation = 0, 0 else: @@ -278,7 +279,9 @@ class Light(HomeAccessory): if CHAR_COLOR_TEMPERATURE in self.chars: color_temp = None if self.color_temp_supported: - color_temp = attributes.get(ATTR_COLOR_TEMP) + color_temp_kelvin = attributes.get(ATTR_COLOR_TEMP_KELVIN) + if color_temp_kelvin is not None: + color_temp = color_temperature_kelvin_to_mired(color_temp_kelvin) elif color_mode == ColorMode.WHITE: color_temp = self.min_mireds if isinstance(color_temp, (int, float)): diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index b26016b8adc314bf7bfca02597a7eecac1ea605b..55519fdf6f77eee4630da5575d83b60571db0c0e 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -96,7 +96,9 @@ class MediaPlayer(HomeAccessory): if FEATURE_ON_OFF in feature_list: name = self.generate_service_name(FEATURE_ON_OFF) - serv_on_off = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_on_off = self.add_preload_service( + SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_ON_OFF + ) serv_on_off.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char( CHAR_ON, value=False, setter_callback=self.set_on_off @@ -104,7 +106,9 @@ class MediaPlayer(HomeAccessory): if FEATURE_PLAY_PAUSE in feature_list: name = self.generate_service_name(FEATURE_PLAY_PAUSE) - serv_play_pause = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_play_pause = self.add_preload_service( + SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_PAUSE + ) serv_play_pause.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char( CHAR_ON, value=False, setter_callback=self.set_play_pause @@ -112,7 +116,9 @@ class MediaPlayer(HomeAccessory): if FEATURE_PLAY_STOP in feature_list: name = self.generate_service_name(FEATURE_PLAY_STOP) - serv_play_stop = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_play_stop = self.add_preload_service( + SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_STOP + ) serv_play_stop.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char( CHAR_ON, value=False, setter_callback=self.set_play_stop @@ -120,7 +126,9 @@ class MediaPlayer(HomeAccessory): if FEATURE_TOGGLE_MUTE in feature_list: name = self.generate_service_name(FEATURE_TOGGLE_MUTE) - serv_toggle_mute = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_toggle_mute = self.add_preload_service( + SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_TOGGLE_MUTE + ) serv_toggle_mute.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char( CHAR_ON, value=False, setter_callback=self.set_toggle_mute diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index aa064cfc01256ec35db6f555a4ca99b250c16e4f..fb808eff8b0f13f37a1754d7db221735d622750f 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -131,7 +131,7 @@ class RemoteInputSelectAccessory(HomeAccessory): ) for index, source in enumerate(self.sources): serv_input = self.add_preload_service( - SERV_INPUT_SOURCE, [CHAR_IDENTIFIER, CHAR_NAME] + SERV_INPUT_SOURCE, [CHAR_IDENTIFIER, CHAR_NAME], unique_id=source ) serv_tv.add_linked_service(serv_input) serv_input.configure_char(CHAR_CONFIGURED_NAME, value=source) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 1598df015c510f733e70747407f6c98bf7b6d1ce..f79185c64b15e5ebd9212d3fb93a7f6a945ae27a 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -259,7 +259,7 @@ class SelectSwitch(HomeAccessory): options = state.attributes[ATTR_OPTIONS] for option in options: serv_option = self.add_preload_service( - SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE] + SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE], unique_id=option ) serv_option.configure_char( CHAR_NAME, value=cleanup_name_for_homekit(option) diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index 776fe6f3110b4469a5d83faa5af3e0b1b03e35f1..b9b2ad6ce8f4482ba5c62e1832a73973a3652ab9 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -42,12 +42,14 @@ class DeviceTriggerAccessory(HomeAccessory): for idx, trigger in enumerate(device_triggers): type_ = trigger["type"] subtype = trigger.get("subtype") + unique_id = f'{type_}-{subtype or ""}' trigger_name = ( f"{type_.title()} {subtype.title()}" if subtype else type_.title() ) serv_stateless_switch = self.add_preload_service( SERV_STATELESS_PROGRAMMABLE_SWITCH, [CHAR_NAME, CHAR_SERVICE_LABEL_INDEX], + unique_id=unique_id, ) self.triggers.append( serv_stateless_switch.configure_char( @@ -60,7 +62,9 @@ class DeviceTriggerAccessory(HomeAccessory): serv_stateless_switch.configure_char( CHAR_SERVICE_LABEL_INDEX, value=idx + 1 ) - serv_service_label = self.add_preload_service(SERV_SERVICE_LABEL) + serv_service_label = self.add_preload_service( + SERV_SERVICE_LABEL, unique_id=unique_id + ) serv_service_label.configure_char(CHAR_SERVICE_LABEL_NAMESPACE, value=1) serv_stateless_switch.add_linked_service(serv_service_label) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 445b73cccbe5544504ad8d4f8a67449a08818d3d..ee02ea1a576f8c2e2594caa0f065700c8a4fd30f 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -431,10 +431,15 @@ def get_persist_filename_for_entry_id(entry_id: str) -> str: def get_aid_storage_filename_for_entry_id(entry_id: str) -> str: - """Determine the ilename of homekit aid storage file.""" + """Determine the filename of homekit aid storage file.""" return f"{DOMAIN}.{entry_id}.aids" +def get_iid_storage_filename_for_entry_id(entry_id: str) -> str: + """Determine the filename of homekit iid storage file.""" + return f"{DOMAIN}.{entry_id}.iids" + + def get_persist_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> str: """Determine the path to the homekit state file.""" return hass.config.path(STORAGE_DIR, get_persist_filename_for_entry_id(entry_id)) @@ -447,6 +452,13 @@ def get_aid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> ) +def get_iid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> str: + """Determine the path to the homekit iid storage file.""" + return hass.config.path( + STORAGE_DIR, get_iid_storage_filename_for_entry_id(entry_id) + ) + + def _format_version_part(version_part: str) -> str: return str(max(0, min(MAX_VERSION_PART, coerce_int(version_part)))) @@ -466,14 +478,15 @@ def _is_zero_but_true(value: Any) -> bool: return convert_to_float(value) == 0 -def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str) -> bool: +def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str) -> None: """Remove the state files from disk.""" - persist_file_path = get_persist_fullpath_for_entry_id(hass, entry_id) - aid_storage_path = get_aid_storage_fullpath_for_entry_id(hass, entry_id) - os.unlink(persist_file_path) - if os.path.exists(aid_storage_path): - os.unlink(aid_storage_path) - return True + for path in ( + get_persist_fullpath_for_entry_id(hass, entry_id), + get_aid_storage_fullpath_for_entry_id(hass, entry_id), + get_iid_storage_fullpath_for_entry_id(hass, entry_id), + ): + if os.path.exists(path): + os.unlink(path) def _get_test_socket() -> socket.socket: diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index 204fa1bb3f828b6905ef58de3b44ca3fb977738a..a466d15db58fd01de5809886b760112e1266cbd1 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -18,11 +18,13 @@ from homeassistant.const import ( STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, + Platform, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import KNOWN_DEVICES +from .connection import HKDevice from .entity import HomeKitEntity ICON = "mdi:security" @@ -49,15 +51,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit alarm control panel.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_service(service: Service) -> bool: if service.type != ServicesTypes.SECURITY_SYSTEM: return False info = {"aid": service.accessory.aid, "iid": service.iid} - async_add_entities([HomeKitAlarmControlPanelEntity(conn, info)], True) + entity = HomeKitAlarmControlPanelEntity(conn, info) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.ALARM_CONTROL_PANEL + ) + async_add_entities([entity]) return True conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index c980e31b50cf0616ad30bad5ab7edd9b701ca3d8..8115023fa52067cba2dd3b9ea6d48d48ad7bf960 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -158,7 +159,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit lighting.""" - hkid = config_entry.data["AccessoryPairingID"] + hkid: str = config_entry.data["AccessoryPairingID"] conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback @@ -174,7 +175,11 @@ async def async_setup_entry( ): return False info = {"aid": service.accessory.aid, "iid": service.iid} - async_add_entities([entity_class(conn, info)], True) + entity: HomeKitEntity = entity_class(conn, info) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.BINARY_SENSOR + ) + async_add_entities([entity]) return True conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py index d5a8bc733adbbff2baee4149279d7493eb953ffa..4ce2b425a5eae84cc432a486bfdb2e659ecb36d9 100644 --- a/homeassistant/components/homekit_controller/button.py +++ b/homeassistant/components/homekit_controller/button.py @@ -16,6 +16,7 @@ from homeassistant.components.button import ( ButtonEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -63,12 +64,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit buttons.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_characteristic(char: Characteristic) -> bool: - entities = [] + entities: list[HomeKitButton | HomeKitEcobeeClearHoldButton] = [] info = {"aid": char.service.accessory.aid, "iid": char.service.iid} if description := BUTTON_ENTITIES.get(char.type): @@ -78,6 +79,11 @@ async def async_setup_entry( else: return False + for entity in entities: + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.BUTTON + ) + async_add_entities(entities, True) return True diff --git a/homeassistant/components/homekit_controller/camera.py b/homeassistant/components/homekit_controller/camera.py index 510c0c2f522e903553e978c1f148d151b9c8d045..35a4b089641f15eddad94527344109249e173af7 100644 --- a/homeassistant/components/homekit_controller/camera.py +++ b/homeassistant/components/homekit_controller/camera.py @@ -6,10 +6,12 @@ from aiohomekit.model.services import ServicesTypes from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import KNOWN_DEVICES +from .connection import HKDevice from .entity import AccessoryEntity @@ -39,8 +41,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit sensors.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_accessory(accessory: Accessory) -> bool: @@ -51,7 +53,11 @@ async def async_setup_entry( return False info = {"aid": accessory.aid, "iid": stream_mgmt.iid} - async_add_entities([HomeKitCamera(conn, info)], True) + entity = HomeKitCamera(conn, info) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.CAMERA + ) + async_add_entities([entity]) return True conn.add_accessory_factory(async_add_accessory) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 41788eb4cb299b86a49c01ad98a4c0e7817c82fe..de42243a6bbb788bebed5dd9ec36fd053046dba6 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -32,11 +32,12 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import KNOWN_DEVICES +from .connection import HKDevice from .entity import HomeKitEntity _LOGGER = logging.getLogger(__name__) @@ -92,15 +93,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit climate.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_service(service: Service) -> bool: if not (entity_class := ENTITY_TYPES.get(service.type)): return False info = {"aid": service.accessory.aid, "iid": service.iid} - async_add_entities([entity_class(conn, info)], True) + entity: HomeKitEntity = entity_class(conn, info) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.CLIMATE + ) + async_add_entities([entity]) return True conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 62144077a9471995e66ba39ed915072644a1d7d4..da4ccfe9f9a8af9f06d7cf1028b525bfe624b0a0 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -15,7 +15,7 @@ from aiohomekit.controller.abstract import ( from aiohomekit.exceptions import AuthenticationError from aiohomekit.model.categories import Categories from aiohomekit.model.status_flags import StatusFlags -from aiohomekit.utils import domain_supported, domain_to_name +from aiohomekit.utils import domain_supported, domain_to_name, serialize_broadcast_key import voluptuous as vol from homeassistant import config_entries @@ -577,6 +577,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): pairing.id, accessories_state.config_num, accessories_state.accessories.serialize(), + serialize_broadcast_key(accessories_state.broadcast_key), ) return self.async_create_entry(title=name, data=pairing_data) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 05a0a589bf1a9fbb3ae2dbcce6fb95f72bab2ab0..320df67114458fb9ef830cbb0ef2c3f0d20d6d35 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -19,9 +19,9 @@ from aiohomekit.model.characteristics import Characteristic from aiohomekit.model.services import Service from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_VIA_DEVICE -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr +from homeassistant.const import ATTR_VIA_DEVICE, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.event import async_track_time_interval @@ -35,6 +35,7 @@ from .const import ( IDENTIFIER_LEGACY_ACCESSORY_ID, IDENTIFIER_LEGACY_SERIAL_NUMBER, IDENTIFIER_SERIAL_NUMBER, + STARTUP_EXCEPTIONS, ) from .device_trigger import async_fire_triggers, async_setup_triggers_for_entry @@ -79,9 +80,7 @@ class HKDevice: connection: Controller = hass.data[CONTROLLER] - self.pairing = connection.load_pairing( - self.pairing_data["AccessoryPairingID"], self.pairing_data - ) + self.pairing = connection.load_pairing(self.unique_id, self.pairing_data) # A list of callbacks that turn HK accessories into entities self.accessory_factories: list[AddAccessoryCb] = [] @@ -178,6 +177,25 @@ class HKDevice: self.available = available async_dispatcher_send(self.hass, self.signal_state_updated) + async def _async_retry_populate_ble_accessory_state(self, event: Event) -> None: + """Try again to populate the BLE accessory state. + + If the accessory was asleep at startup we need to retry + since we continued on to allow startup to proceed. + + If this fails the state may be inconsistent, but will + get corrected as soon as the accessory advertises again. + """ + try: + await self.pairing.async_populate_accessories_state(force_update=True) + except STARTUP_EXCEPTIONS as ex: + _LOGGER.debug( + "Failed to populate BLE accessory state for %s, accessory may be sleeping" + " and will be retried the next time it advertises: %s", + self.config_entry.title, + ex, + ) + async def async_setup(self) -> None: """Prepare to use a paired HomeKit device in Home Assistant.""" pairing = self.pairing @@ -194,12 +212,21 @@ class HKDevice: # Ideally we would know which entities we are about to add # so we only poll those chars but that is not possible # yet. + attempts = None if self.hass.state == CoreState.running else 1 try: - await self.pairing.async_populate_accessories_state(force_update=True) + await self.pairing.async_populate_accessories_state( + force_update=True, attempts=attempts + ) except AccessoryNotFoundError: if transport != Transport.BLE or not pairing.accessories: # BLE devices may sleep and we can't force a connection raise + entry.async_on_unload( + self.hass.bus.async_listen( + EVENT_HOMEASSISTANT_STARTED, + self._async_retry_populate_ble_accessory_state, + ) + ) entry.async_on_unload(pairing.dispatcher_connect(self.process_new_events)) entry.async_on_unload( @@ -253,7 +280,12 @@ class HKDevice: identifiers.add((IDENTIFIER_SERIAL_NUMBER, accessory.serial_number)) device_info = DeviceInfo( - identifiers=identifiers, + identifiers={ + ( + IDENTIFIER_ACCESSORY_ID, + f"{self.unique_id}:aid:{accessory.aid}", + ) + }, name=accessory.name, manufacturer=accessory.manufacturer, model=accessory.model, @@ -317,26 +349,86 @@ class HKDevice: self.unique_id, accessory.aid, ) + device_registry.async_update_device( + device.id, + new_identifiers={ + ( + IDENTIFIER_ACCESSORY_ID, + f"{self.unique_id}:aid:{accessory.aid}", + ) + }, + ) + + @callback + def async_migrate_unique_id( + self, old_unique_id: str, new_unique_id: str, platform: str + ) -> None: + """Migrate legacy unique IDs to new format.""" + _LOGGER.debug( + "Checking if unique ID %s on %s needs to be migrated", + old_unique_id, + platform, + ) + entity_registry = er.async_get(self.hass) + # async_get_entity_id wants the "homekit_controller" domain + # in the platform field and the actual platform in the domain + # field for historical reasons since everything used to be + # PLATFORM.INTEGRATION instead of INTEGRATION.PLATFORM + if ( + entity_id := entity_registry.async_get_entity_id( + platform, DOMAIN, old_unique_id + ) + ) is None: + _LOGGER.debug("Unique ID %s does not need to be migrated", old_unique_id) + return + if new_entity_id := entity_registry.async_get_entity_id( + platform, DOMAIN, new_unique_id + ): + _LOGGER.debug( + "Unique ID %s is already in use by %s (system may have been downgraded)", + new_unique_id, + new_entity_id, + ) + return + _LOGGER.debug( + "Migrating unique ID for entity %s (%s -> %s)", + entity_id, + old_unique_id, + new_unique_id, + ) + entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + @callback + def async_remove_legacy_device_serial_numbers(self) -> None: + """Migrate remove legacy serial numbers from devices. + + We no longer use serial numbers as device identifiers + since they are not reliable, and the HomeKit spec + does not require them to be stable. + """ + _LOGGER.debug( + "Removing legacy serial numbers from device registry entries for pairing %s", + self.unique_id, + ) - new_identifiers = { + device_registry = dr.async_get(self.hass) + for accessory in self.entity_map.accessories: + identifiers = { ( IDENTIFIER_ACCESSORY_ID, f"{self.unique_id}:aid:{accessory.aid}", ) } + legacy_serial_identifier = ( + IDENTIFIER_SERIAL_NUMBER, + accessory.serial_number, + ) - if not self.unreliable_serial_numbers: - new_identifiers.add((IDENTIFIER_SERIAL_NUMBER, accessory.serial_number)) - else: - _LOGGER.debug( - "Not migrating serial number identifier for %s:aid:%s (it is wrong, not unique or unreliable)", - self.unique_id, - accessory.aid, - ) + device = device_registry.async_get_device(identifiers=identifiers) + if not device or legacy_serial_identifier not in device.identifiers: + continue - device_registry.async_update_device( - device.id, new_identifiers=new_identifiers - ) + device_registry.async_update_device(device.id, new_identifiers=identifiers) @callback def async_create_devices(self) -> None: @@ -416,6 +508,9 @@ class HKDevice: # Migrate to new device ids self.async_migrate_devices() + # Remove any of the legacy serial numbers from the device registry + self.async_remove_legacy_device_serial_numbers() + self.async_create_devices() # Load any triggers for this config entry diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 5ea8205260ec247fc9df24ca6b23a1ab8ab51843..8c7db4dad00c369bcb22967d6f723b791d2766e4 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -1,6 +1,12 @@ """Constants for the homekit_controller component.""" +import asyncio from typing import Final +from aiohomekit.exceptions import ( + AccessoryDisconnectedError, + AccessoryNotFoundError, + EncryptionError, +) from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes @@ -94,3 +100,10 @@ CHARACTERISTIC_PLATFORMS = { # Device classes DEVICE_CLASS_ECOBEE_MODE: Final = "homekit_controller__ecobee_mode" + +STARTUP_EXCEPTIONS = ( + asyncio.TimeoutError, + AccessoryNotFoundError, + EncryptionError, + AccessoryDisconnectedError, +) diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 6cbc623596e61ecc80f4ade0f80abc31fabb8d5b..d4feeccc77a979f86782ff62e33782114a337f18 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -14,11 +14,18 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING +from homeassistant.const import ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import KNOWN_DEVICES +from .connection import HKDevice from .entity import HomeKitEntity STATE_STOPPED = "stopped" @@ -42,15 +49,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit covers.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_service(service: Service) -> bool: if not (entity_class := ENTITY_TYPES.get(service.type)): return False info = {"aid": service.accessory.aid, "iid": service.iid} - async_add_entities([entity_class(conn, info)], True) + entity: HomeKitEntity = entity_class(conn, info) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.COVER + ) + async_add_entities([entity]) return True conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index ad99e65f2d80e4e89ad6b22cb685f19b5ec382e9..a4e1b2b41b3d593406816328b0968a1f70bc7056 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -121,8 +121,8 @@ class HomeKitEntity(Entity): self._char_name = char.service.value(CharacteristicsTypes.NAME) @property - def unique_id(self) -> str: - """Return the ID of this device.""" + def old_unique_id(self) -> str: + """Return the OLD ID of this device.""" info = self.accessory_info serial = info.value(CharacteristicsTypes.SERIAL_NUMBER) if valid_serial_number(serial): @@ -130,6 +130,11 @@ class HomeKitEntity(Entity): # Some accessories do not have a serial number return f"homekit-{self._accessory.unique_id}-{self._aid}-{self._iid}" + @property + def unique_id(self) -> str: + """Return the ID of this device.""" + return f"{self._accessory.unique_id}_{self._aid}_{self._iid}" + @property def default_name(self) -> str | None: """Return the default name of the device.""" @@ -175,11 +180,16 @@ class AccessoryEntity(HomeKitEntity): """A HomeKit entity that is related to an entire accessory rather than a specific service or characteristic.""" @property - def unique_id(self) -> str: - """Return the ID of this device.""" + def old_unique_id(self) -> str: + """Return the old ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-aid:{self._aid}" + @property + def unique_id(self) -> str: + """Return the ID of this device.""" + return f"{self._accessory.unique_id}_{self._aid}" + class CharacteristicEntity(HomeKitEntity): """ @@ -197,7 +207,12 @@ class CharacteristicEntity(HomeKitEntity): super().__init__(accessory, devinfo) @property - def unique_id(self) -> str: - """Return the ID of this device.""" + def old_unique_id(self) -> str: + """Return the old ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-aid:{self._aid}-sid:{self._char.service.iid}-cid:{self._char.iid}" + + @property + def unique_id(self) -> str: + """Return the ID of this device.""" + return f"{self._accessory.unique_id}_{self._aid}_{self._char.service.iid}_{self._char.iid}" diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 03f4dade67420624b440193ed0b72eebebd86190..cdd9c3e803c8116a48e81431d73a93b6a77ffe86 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -13,6 +13,7 @@ from homeassistant.components.fan import ( FanEntityFeature, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -21,6 +22,7 @@ from homeassistant.util.percentage import ( ) from . import KNOWN_DEVICES +from .connection import HKDevice from .entity import HomeKitEntity # 0 is clockwise, 1 is counter-clockwise. The match to forward and reverse is so that @@ -193,15 +195,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit fans.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_service(service: Service) -> bool: if not (entity_class := ENTITY_TYPES.get(service.type)): return False info = {"aid": service.accessory.aid, "iid": service.iid} - async_add_entities([entity_class(conn, info)], True) + entity: HomeKitEntity = entity_class(conn, info) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.FAN + ) + async_add_entities([entity]) return True conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index adc1b1c7935fd857c8e326cba8fc9e109d29e6b4..e396b3c9c972f8daaba71f21297d77d86c7800e8 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -16,10 +16,12 @@ from homeassistant.components.humidifier import ( HumidifierEntityFeature, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import KNOWN_DEVICES +from .connection import HKDevice from .entity import HomeKitEntity HK_MODE_TO_HA = { @@ -243,11 +245,16 @@ class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): ) @property - def unique_id(self) -> str: - """Return the ID of this device.""" + def old_unique_id(self) -> str: + """Return the old ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-{self._iid}-{self.device_class}" + @property + def unique_id(self) -> str: + """Return the ID of this device.""" + return f"{self._accessory.unique_id}_{self._iid}_{self.device_class}" + async def async_setup_entry( hass: HomeAssistant, @@ -255,8 +262,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit humidifer.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_service(service: Service) -> bool: @@ -265,7 +272,7 @@ async def async_setup_entry( info = {"aid": service.accessory.aid, "iid": service.iid} - entities: list[HumidifierEntity] = [] + entities: list[HomeKitHumidifier | HomeKitDehumidifier] = [] if service.has(CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD): entities.append(HomeKitHumidifier(conn, info)) @@ -273,7 +280,12 @@ async def async_setup_entry( if service.has(CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD): entities.append(HomeKitDehumidifier(conn, info)) - async_add_entities(entities, True) + for entity in entities: + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.HUMIDIFIER + ) + + async_add_entities(entities) return True diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index 010411c60d073884ca1c274f63789b55bead9470..5bf810a89db819ae6c4ab5a508b7bfa1851c7167 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -14,10 +14,12 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import KNOWN_DEVICES +from .connection import HKDevice from .entity import HomeKitEntity @@ -27,15 +29,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit lightbulb.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_service(service: Service) -> bool: if service.type != ServicesTypes.LIGHTBULB: return False info = {"aid": service.accessory.aid, "iid": service.iid} - async_add_entities([HomeKitLight(conn, info)], True) + entity = HomeKitLight(conn, info) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.LIGHT + ) + async_add_entities([entity]) return True conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 8e8919ae4f8ac331d272554a1111aa7384924e3e..a6c8a3672a3da36c21d69cbf83006188d1769e1f 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -13,11 +13,13 @@ from homeassistant.const import ( STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED, + Platform, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import KNOWN_DEVICES +from .connection import HKDevice from .entity import HomeKitEntity CURRENT_STATE_MAP = { @@ -38,15 +40,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit lock.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_service(service: Service) -> bool: if service.type != ServicesTypes.LOCK_MECHANISM: return False info = {"aid": service.accessory.aid, "iid": service.iid} - async_add_entities([HomeKitLock(conn, info)], True) + entity = HomeKitLock(conn, info) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.LOCK + ) + async_add_entities([entity]) return True conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 53ec03e541094dfed1a47e9c118c89897aa81a8a..18884d5930797053c7be4ddbb34777515d3a5a71 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==2.0.2"], + "requirements": ["aiohomekit==2.2.13"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."], "bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }], "dependencies": ["bluetooth", "zeroconf"], diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 5c791f165e2a75cc6ec8660646538540d0da9724..4efa7dbce1c21f78d6dfc44b2f5e4b878eeb9e86 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -19,10 +19,12 @@ from homeassistant.components.media_player import ( MediaPlayerState, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import KNOWN_DEVICES +from .connection import HKDevice from .entity import HomeKitEntity _LOGGER = logging.getLogger(__name__) @@ -41,15 +43,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit television.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_service(service: Service) -> bool: if service.type != ServicesTypes.TELEVISION: return False info = {"aid": service.accessory.aid, "iid": service.iid} - async_add_entities([HomeKitTelevision(conn, info)], True) + entity = HomeKitTelevision(conn, info) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.MEDIA_PLAYER + ) + async_add_entities([entity]) return True conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index 6347ccb2a561057fc41b7585cd4cd79988ef5c87..a20ba83e80a782e9208fc36b93e9fd3652678e75 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -16,6 +16,7 @@ from homeassistant.components.number import ( NumberEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -59,12 +60,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit numbers.""" - hkid = config_entry.data["AccessoryPairingID"] + hkid: str = config_entry.data["AccessoryPairingID"] conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_characteristic(char: Characteristic) -> bool: - entities = [] + entities: list[HomeKitNumber] = [] info = {"aid": char.service.accessory.aid, "iid": char.service.iid} if description := NUMBER_ENTITIES.get(char.type): @@ -72,6 +73,11 @@ async def async_setup_entry( else: return False + for entity in entities: + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.NUMBER + ) + async_add_entities(entities, True) return True diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py index a22f79d675b30a5dccee5eaff3e00f37790843ff..ca5eaec4dc53bb5980c90ccdd4339ded727d0209 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -5,10 +5,12 @@ from aiohomekit.model.characteristics import Characteristic, CharacteristicsType from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import KNOWN_DEVICES +from .connection import HKDevice from .const import DEVICE_CLASS_ECOBEE_MODE from .entity import CharacteristicEntity @@ -58,14 +60,18 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit select entities.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_characteristic(char: Characteristic) -> bool: if char.type == CharacteristicsTypes.VENDOR_ECOBEE_CURRENT_MODE: info = {"aid": char.service.accessory.aid, "iid": char.service.iid} - async_add_entities([EcobeeModeSelect(conn, info, char)]) + entity = EcobeeModeSelect(conn, info, char) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.SELECT + ) + async_add_entities([entity]) return True return False diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 564eb5ba9c605c2e1a3321b9f2489c93b5dd128b..49047b28eaef0ec840059517bb81a977f176f87b 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -10,7 +10,10 @@ from aiohomekit.model.characteristics import Characteristic, CharacteristicsType from aiohomekit.model.characteristics.const import ThreadNodeCapabilities, ThreadStatus from aiohomekit.model.services import Service, ServicesTypes -from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.components.bluetooth import ( + async_ble_device_from_address, + async_last_service_info, +) from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -29,7 +32,9 @@ from homeassistant.const import ( POWER_WATT, PRESSURE_HPA, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + SOUND_PRESSURE_DB, TEMP_CELSIUS, + Platform, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory @@ -178,7 +183,7 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { key=CharacteristicsTypes.VENDOR_EVE_ENERGY_KW_HOUR, name="Energy kWh", device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), CharacteristicsTypes.VENDOR_EVE_ENERGY_VOLTAGE: HomeKitSensorEntityDescription( @@ -305,6 +310,12 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { entity_category=EntityCategory.DIAGNOSTIC, format=thread_status_to_str, ), + CharacteristicsTypes.VENDOR_NETATMO_NOISE: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.VENDOR_NETATMO_NOISE, + name="Noise", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SOUND_PRESSURE_DB, + ), } @@ -556,17 +567,22 @@ class RSSISensor(HomeKitEntity, SensorEntity): return "Signal strength" @property - def unique_id(self) -> str: - """Return the ID of this device.""" + def old_unique_id(self) -> str: + """Return the old ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-rssi" + @property + def unique_id(self) -> str: + """Return the ID of this device.""" + return f"{self._accessory.unique_id}_rssi" + @property def native_value(self) -> int | None: """Return the current rssi value.""" address = self._accessory.pairing_data["AccessoryAddress"] - ble_device = async_ble_device_from_address(self.hass, address) - return ble_device.rssi if ble_device else None + last_service_info = async_last_service_info(self.hass, address) + return last_service_info.rssi if last_service_info else None async def async_setup_entry( @@ -587,7 +603,11 @@ async def async_setup_entry( ) and not service.has(required_char): return False info = {"aid": service.accessory.aid, "iid": service.iid} - async_add_entities([entity_class(conn, info)]) + entity: HomeKitSensor = entity_class(conn, info) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.SENSOR + ) + async_add_entities([entity]) return True conn.add_listener(async_add_service) @@ -599,7 +619,11 @@ async def async_setup_entry( if description.probe and not description.probe(char): return False info = {"aid": char.service.accessory.aid, "iid": char.service.iid} - async_add_entities([SimpleSensor(conn, info, char, description)]) + entity = SimpleSensor(conn, info, char, description) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.SENSOR + ) + async_add_entities([entity]) return True @@ -614,7 +638,11 @@ async def async_setup_entry( service_type=ServicesTypes.ACCESSORY_INFORMATION ) info = {"aid": accessory.aid, "iid": accessory_info.iid} - async_add_entities([RSSISensor(conn, info)]) + entity = RSSISensor(conn, info) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.SENSOR + ) + async_add_entities([entity]) return True conn.add_accessory_factory(async_add_accessory) diff --git a/homeassistant/components/homekit_controller/storage.py b/homeassistant/components/homekit_controller/storage.py index 51d8ce4ffd30a78dd733a2d441dd8e2af320338f..a5afb07620ae19be4b2afbf8887837c35e158126 100644 --- a/homeassistant/components/homekit_controller/storage.py +++ b/homeassistant/components/homekit_controller/storage.py @@ -3,7 +3,9 @@ from __future__ import annotations import logging -from typing import Any, TypedDict +from typing import Any + +from aiohomekit.characteristic_cache import Pairing, StorageLayout from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store @@ -16,19 +18,6 @@ ENTITY_MAP_SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) -class Pairing(TypedDict): - """A versioned map of entity metadata as presented by aiohomekit.""" - - config_num: int - accessories: list[Any] - - -class StorageLayout(TypedDict): - """Cached pairing metadata needed by aiohomekit.""" - - pairings: dict[str, Pairing] - - class EntityMapStorage: """ Holds a cache of entity structure data from a paired HomeKit device. @@ -67,11 +56,17 @@ class EntityMapStorage: @callback def async_create_or_update_map( - self, homekit_id: str, config_num: int, accessories: list[Any] + self, + homekit_id: str, + config_num: int, + accessories: list[Any], + broadcast_key: str | None = None, ) -> Pairing: """Create a new pairing cache.""" _LOGGER.debug("Creating or updating entity map for %s", homekit_id) - data = Pairing(config_num=config_num, accessories=accessories) + data = Pairing( + config_num=config_num, accessories=accessories, broadcast_key=broadcast_key + ) self.storage_data[homekit_id] = data self._async_schedule_save() return data diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index c537233de7ead4014152a915680729186fb0e958..d1e06e585b0ea068d5dbb43fa09fc382bef5609a 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -14,6 +14,7 @@ from aiohomekit.model.services import Service, ServicesTypes from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -182,7 +183,7 @@ class DeclarativeCharacteristicSwitch(CharacteristicEntity, SwitchEntity): ) -ENTITY_TYPES = { +ENTITY_TYPES: dict[str, type[HomeKitSwitch] | type[HomeKitValve]] = { ServicesTypes.SWITCH: HomeKitSwitch, ServicesTypes.OUTLET: HomeKitSwitch, ServicesTypes.VALVE: HomeKitValve, @@ -195,15 +196,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Homekit switches.""" - hkid = config_entry.data["AccessoryPairingID"] - conn = hass.data[KNOWN_DEVICES][hkid] + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback def async_add_service(service: Service) -> bool: if not (entity_class := ENTITY_TYPES.get(service.type)): return False info = {"aid": service.accessory.aid, "iid": service.iid} - async_add_entities([entity_class(conn, info)], True) + entity: HomeKitSwitch | HomeKitValve = entity_class(conn, info) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.SWITCH + ) + async_add_entities([entity]) return True conn.add_listener(async_add_service) @@ -214,9 +219,11 @@ async def async_setup_entry( return False info = {"aid": char.service.accessory.aid, "iid": char.service.iid} - async_add_entities( - [DeclarativeCharacteristicSwitch(conn, info, char, description)], True + entity = DeclarativeCharacteristicSwitch(conn, info, char, description) + conn.async_migrate_unique_id( + entity.old_unique_id, entity.unique_id, Platform.SWITCH ) + async_add_entities([entity]) return True conn.add_char_factory(async_add_characteristic) diff --git a/homeassistant/components/homekit_controller/translations/select.pl.json b/homeassistant/components/homekit_controller/translations/select.pl.json index 0a59529b6ba7ef4e0064bc99e71abcdc30ce76ba..7a9139d109f486cd91dee45d1aecd782cfa58b4a 100644 --- a/homeassistant/components/homekit_controller/translations/select.pl.json +++ b/homeassistant/components/homekit_controller/translations/select.pl.json @@ -1,9 +1,9 @@ { "state": { "homekit_controller__ecobee_mode": { - "away": "Poza domem", - "home": "W domu", - "sleep": "U\u015bpiony" + "away": "poza domem", + "home": "w domu", + "sleep": "u\u015bpiony" } } } \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/translations/sensor.pl.json b/homeassistant/components/homekit_controller/translations/sensor.pl.json new file mode 100644 index 0000000000000000000000000000000000000000..a11105cfc15804ecef33ef52af1d15fcd333de7b --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/sensor.pl.json @@ -0,0 +1,21 @@ +{ + "state": { + "homekit_controller__thread_node_capabilities": { + "border_router_capable": "funkcje routera granicznego", + "full": "pe\u0142ne urz\u0105dzenie ko\u0144cowe", + "minimal": "podstawowe urz\u0105dzenie ko\u0144cowe", + "none": "brak", + "router_eligible": "urz\u0105dzenie ko\u0144cowe kwalifikuj\u0105ce si\u0119 jako router", + "sleepy": "u\u015bpione urz\u0105dzenie ko\u0144cowe" + }, + "homekit_controller__thread_status": { + "border_router": "router graniczny", + "child": "dziecko", + "detached": "od\u0142\u0105czony", + "disabled": "wy\u0142\u0105czony", + "joining": "do\u0142\u0105czanie", + "leader": "lider", + "router": "router" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 6d0fe0c1b9641aae1e4a4c5e18ed25f952f24f38..db35a5d3ee54e06e9b1ffb663963794c1f20a9e4 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -142,8 +142,7 @@ async def async_setup_entry( elif isinstance(group, AsyncSecurityZoneGroup): entities.append(HomematicipSecurityZoneSensorGroup(hap, device=group)) - if entities: - async_add_entities(entities) + async_add_entities(entities) class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEntity): diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index b3d2236f05a67443cbe8d035b348b79882aad252..2fc9f8fd12d6d0e923af656a1eac077e94f6a0fc 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -51,8 +51,7 @@ async def async_setup_entry( if isinstance(device, AsyncHeatingGroup): entities.append(HomematicipHeatingGroup(hap, device)) - if entities: - async_add_entities(entities) + async_add_entities(entities) class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 31faea875a4abf430c8d16b44b80785a758a59c3..7038c423df08d92f9a1096398685976d8881e7b1 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -62,8 +62,7 @@ async def async_setup_entry( if isinstance(group, AsyncExtendedLinkedShutterGroup): entities.append(HomematicipCoverShutterGroup(hap, group)) - if entities: - async_add_entities(entities) + async_add_entities(entities) class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index c43ac51002314cec03d83f81417c46b00c4cffdb..09a5cd7ec34de2157dde1f65364dbb7ea3f032ff 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -62,8 +62,7 @@ async def async_setup_entry( ): entities.append(HomematicipDimmer(hap, device)) - if entities: - async_add_entities(entities) + async_add_entities(entities) class HomematicipLight(HomematicipGenericEntity, LightEntity): diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index bb9dd8021ed85859799fc723308918d20f6766fd..03aaa7626b753e0c6a70b5d890f013614511fbfa 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -130,8 +130,7 @@ async def async_setup_entry( entities.append(HomematicpTemperatureExternalSensorCh2(hap, device)) entities.append(HomematicpTemperatureExternalSensorDelta(hap, device)) - if entities: - async_add_entities(entities) + async_add_entities(entities) class HomematicipAccesspointDutyCycle(HomematicipGenericEntity, SensorEntity): diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 1b39633bc15ae57a5c34b04f51ca28ce6377c87c..770687ef50ddcceeeddb5d6824486744623a1341 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -82,8 +82,7 @@ async def async_setup_entry( if isinstance(group, (AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup)): entities.append(HomematicipGroupSwitch(hap, group)) - if entities: - async_add_entities(entities) + async_add_entities(entities) class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity): diff --git a/homeassistant/components/homematicip_cloud/translations/nb.json b/homeassistant/components/homematicip_cloud/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index 3acbae95441340d6bc4faec907391546265762bb..a52e99bfa4e7022cfef212ba728666b6351a6c60 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -64,8 +64,7 @@ async def async_setup_entry( entities.append(HomematicipHomeWeather(hap)) - if entities: - async_add_entities(entities) + async_add_entities(entities) class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity): diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 6fc1d38ec125279e66d823342af4ec6b519c325f..4baaff8835d0c6d07ddd45867c155bafc5d0f87b 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -131,7 +131,7 @@ SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( name="Total water usage", native_unit_of_measurement=VOLUME_CUBIC_METERS, icon="mdi:gauge", - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, ), ) diff --git a/homeassistant/components/homewizard/translations/nb.json b/homeassistant/components/homewizard/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..5a2a5b3f9124758688cc7305d2a94f023b78560c --- /dev/null +++ b/homeassistant/components/homewizard/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown_error": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 5740cf99f5349063ad204f61ea81f3240b8e7820..17646fa3ed674981b437495f8daff0e6f316b684 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -15,6 +15,7 @@ from huawei_lte_api.Client import Client from huawei_lte_api.Connection import Connection from huawei_lte_api.enums.device import ControlModeEnum from huawei_lte_api.exceptions import ( + LoginErrorInvalidCredentialsException, ResponseErrorException, ResponseErrorLoginRequiredException, ResponseErrorNotSupportedException, @@ -38,7 +39,7 @@ 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, device_registry as dr, @@ -339,6 +340,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: connection = await hass.async_add_executor_job(get_connection) + except LoginErrorInvalidCredentialsException as ex: + raise ConfigEntryAuthFailed from ex except Timeout as ex: raise ConfigEntryNotReady from ex @@ -487,10 +490,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Huawei LTE component.""" - # dicttoxml (used by huawei-lte-api) has uselessly verbose INFO level. - # https://github.com/quandyfactory/dicttoxml/issues/60 - logging.getLogger("dicttoxml").setLevel(logging.WARNING) - if DOMAIN not in hass.data: hass.data[DOMAIN] = HuaweiLteData(hass_config=config, routers={}) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 3dfd38d6304037b9ec68620591a2afed39fbe23f..036eec37d44e2f75007b1c50a255546fc8e045e9 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -1,6 +1,7 @@ """Config flow for the Huawei LTE platform.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any from urllib.parse import urlparse @@ -89,6 +90,70 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors or {}, ) + async def _async_show_reauth_form( + self, + user_input: dict[str, Any], + errors: dict[str, str] | None = None, + ) -> FlowResult: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Optional( + CONF_USERNAME, default=user_input.get(CONF_USERNAME) or "" + ): str, + vol.Optional( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD) or "" + ): str, + } + ), + errors=errors or {}, + ) + + async def _try_connect( + self, user_input: dict[str, Any], errors: dict[str, str] + ) -> Connection | None: + """Try connecting with given data.""" + username = user_input.get(CONF_USERNAME) or "" + password = user_input.get(CONF_PASSWORD) or "" + + def _get_connection() -> Connection: + return Connection( + url=user_input[CONF_URL], + username=username, + password=password, + timeout=CONNECTION_TIMEOUT, + ) + + conn = None + try: + conn = await self.hass.async_add_executor_job(_get_connection) + except LoginErrorUsernameWrongException: + errors[CONF_USERNAME] = "incorrect_username" + except LoginErrorPasswordWrongException: + errors[CONF_PASSWORD] = "incorrect_password" + except LoginErrorUsernamePasswordWrongException: + errors[CONF_USERNAME] = "invalid_auth" + except LoginErrorUsernamePasswordOverrunException: + errors["base"] = "login_attempts_exceeded" + except ResponseErrorException: + _LOGGER.warning("Response error", exc_info=True) + errors["base"] = "response_error" + except Timeout: + _LOGGER.warning("Connection timeout", exc_info=True) + errors[CONF_URL] = "connection_timeout" + except Exception: # pylint: disable=broad-except + _LOGGER.warning("Unknown error connecting to device", exc_info=True) + errors[CONF_URL] = "unknown" + return conn + + @staticmethod + def _logout(conn: Connection) -> None: + try: + conn.user_session.user.logout() # type: ignore[union-attr] + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not logout", exc_info=True) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -108,25 +173,9 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input=user_input, errors=errors ) - def logout() -> None: - try: - conn.user_session.user.logout() # type: ignore[union-attr] - except Exception: # pylint: disable=broad-except - _LOGGER.debug("Could not logout", exc_info=True) - - def try_connect(user_input: dict[str, Any]) -> Connection: - """Try connecting with given credentials.""" - username = user_input.get(CONF_USERNAME) or "" - password = user_input.get(CONF_PASSWORD) or "" - conn = Connection( - user_input[CONF_URL], - username=username, - password=password, - timeout=CONNECTION_TIMEOUT, - ) - return conn - - def get_device_info() -> tuple[GetResponseType, GetResponseType]: + def get_device_info( + conn: Connection, + ) -> tuple[GetResponseType, GetResponseType]: """Get router info.""" client = Client(conn) try: @@ -147,33 +196,17 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): wlan_settings = {} return device_info, wlan_settings - try: - conn = await self.hass.async_add_executor_job(try_connect, user_input) - except LoginErrorUsernameWrongException: - errors[CONF_USERNAME] = "incorrect_username" - except LoginErrorPasswordWrongException: - errors[CONF_PASSWORD] = "incorrect_password" - except LoginErrorUsernamePasswordWrongException: - errors[CONF_USERNAME] = "invalid_auth" - except LoginErrorUsernamePasswordOverrunException: - errors["base"] = "login_attempts_exceeded" - except ResponseErrorException: - _LOGGER.warning("Response error", exc_info=True) - errors["base"] = "response_error" - except Timeout: - _LOGGER.warning("Connection timeout", exc_info=True) - errors[CONF_URL] = "connection_timeout" - except Exception: # pylint: disable=broad-except - _LOGGER.warning("Unknown error connecting to device", exc_info=True) - errors[CONF_URL] = "unknown" + conn = await self._try_connect(user_input, errors) if errors: - await self.hass.async_add_executor_job(logout) return await self._async_show_user_form( user_input=user_input, errors=errors ) + assert conn - info, wlan_settings = await self.hass.async_add_executor_job(get_device_info) - await self.hass.async_add_executor_job(logout) + info, wlan_settings = await self.hass.async_add_executor_job( + get_device_info, conn + ) + await self.hass.async_add_executor_job(self._logout, conn) user_input[CONF_MAC] = get_device_macs(info, wlan_settings) @@ -228,6 +261,38 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } return await self._async_show_user_form(user_input) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry + if not user_input: + return await self._async_show_reauth_form( + user_input={ + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + } + ) + + new_data = {**entry.data, **user_input} + errors: dict[str, str] = {} + conn = await self._try_connect(new_data, errors) + if conn: + await self.hass.async_add_executor_job(self._logout, conn) + if errors: + return await self._async_show_reauth_form( + user_input=user_input, errors=errors + ) + + self.hass.config_entries.async_update_entry(entry, data=new_data) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + class OptionsFlowHandler(config_entries.OptionsFlow): """Huawei LTE options flow.""" diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 910e0e132f16e32878e907e876e52511450f63f7..c658fff1b0f2a42227fde8ba88e52462b59a9eee 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huawei_lte", "requirements": [ - "huawei-lte-api==1.6.1", + "huawei-lte-api==1.6.3", "stringcase==1.2.0", "url-normalize==1.4.3" ], @@ -16,5 +16,5 @@ ], "codeowners": ["@scop", "@fphammerle"], "iot_class": "local_polling", - "loggers": ["huawei_lte_api"] + "loggers": ["huawei_lte_api.Session"] } diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 0c1373192c5c2df2b3139f717b7cbc1ede4c47f5..8f6ec64491bc3a720f89b878dd2b070a43c4adc4 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Not a Huawei LTE device" + "not_huawei_lte": "Not a Huawei LTE device", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "connection_timeout": "Connection timeout", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Enter device access credentials.", + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/homeassistant/components/huawei_lte/translations/bg.json b/homeassistant/components/huawei_lte/translations/bg.json index feb010b214feadccc0b95d55bca6ad1dc7102ca4..8f34e808235fc9fd7c55a9081ef18e84ae7dabbf 100644 --- a/homeassistant/components/huawei_lte/translations/bg.json +++ b/homeassistant/components/huawei_lte/translations/bg.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "\u041d\u0435 \u0435 Huawei LTE \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + "not_huawei_lte": "\u041d\u0435 \u0435 Huawei LTE \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u0442\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u0430" }, "error": { "connection_timeout": "\u0412\u0440\u0435\u043c\u0435\u0442\u043e \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0438\u0437\u0442\u0435\u0447\u0435", @@ -14,6 +15,13 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + }, + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430" + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", diff --git a/homeassistant/components/huawei_lte/translations/ca.json b/homeassistant/components/huawei_lte/translations/ca.json index 903ba2334073f766d230044790152edb2f01c90e..7c872862488c3f1cfa5c2c5de76aaea28c163183 100644 --- a/homeassistant/components/huawei_lte/translations/ca.json +++ b/homeassistant/components/huawei_lte/translations/ca.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "No \u00e9s un dispositiu Huawei LTE" + "not_huawei_lte": "No \u00e9s un dispositiu Huawei LTE", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "connection_timeout": "S'ha acabat el temps d'espera de la connexi\u00f3", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix les credencials d'acc\u00e9s del dispositiu.", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, "user": { "data": { "password": "Contrasenya", diff --git a/homeassistant/components/huawei_lte/translations/de.json b/homeassistant/components/huawei_lte/translations/de.json index 50e7b7a2e5357fc3d63709dcbb11a0d6c3ca9027..8073aef0bf6489432cc911556c3330ba8f2aa73e 100644 --- a/homeassistant/components/huawei_lte/translations/de.json +++ b/homeassistant/components/huawei_lte/translations/de.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Kein Huawei LTE-Ger\u00e4t" + "not_huawei_lte": "Kein Huawei LTE-Ger\u00e4t", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "connection_timeout": "Verbindungszeit\u00fcberschreitung", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Gib die Zugangsdaten f\u00fcr das Ger\u00e4t ein.", + "title": "Integration erneut authentifizieren" + }, "user": { "data": { "password": "Passwort", diff --git a/homeassistant/components/huawei_lte/translations/el.json b/homeassistant/components/huawei_lte/translations/el.json index 8b6def091c994c38dea48f934a40545e3c60baec..f2648a50697e57ccdb4592e6e1e9d5cba5d41bd4 100644 --- a/homeassistant/components/huawei_lte/translations/el.json +++ b/homeassistant/components/huawei_lte/translations/el.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "\u0394\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae Huawei LTE" + "not_huawei_lte": "\u0394\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae Huawei LTE", + "reauth_successful": "\u0397 \u03b5\u03c0\u03b1\u03bd\u03b1\u03c4\u03b1\u03c5\u03c4\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7 \u03ae\u03c4\u03b1\u03bd \u03b5\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2" }, "error": { "connection_timeout": "\u039b\u03ae\u03be\u03b7 \u03c7\u03c1\u03bf\u03bd\u03b9\u03ba\u03bf\u03cd \u03bf\u03c1\u03af\u03bf\u03c5 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", + "username": "\u039f\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7" + }, + "description": "\u0395\u03b9\u03c3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03b1 \u03b4\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03ae\u03c1\u03b9\u03b1 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c4\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2.", + "title": "\u0395\u03c0\u03b1\u03bd\u03b1\u03c4\u03b1\u03c5\u03c4\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7 \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7\u03c2" + }, "user": { "data": { "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", diff --git a/homeassistant/components/huawei_lte/translations/en.json b/homeassistant/components/huawei_lte/translations/en.json index 5636d952b19aea37631770e7ee037c06ddd918c3..134a5372f71b00e258b533964d824a95e1110e6b 100644 --- a/homeassistant/components/huawei_lte/translations/en.json +++ b/homeassistant/components/huawei_lte/translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Not a Huawei LTE device" + "not_huawei_lte": "Not a Huawei LTE device", + "reauth_successful": "Re-authentication was successful" }, "error": { "connection_timeout": "Connection timeout", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Enter device access credentials.", + "title": "Reauthenticate Integration" + }, "user": { "data": { "password": "Password", diff --git a/homeassistant/components/huawei_lte/translations/es.json b/homeassistant/components/huawei_lte/translations/es.json index a88f35ba3d5a8bd8a94958b60af9abde21b4b04b..af2a155d5e535b4eb38f86907235286c9100fc18 100644 --- a/homeassistant/components/huawei_lte/translations/es.json +++ b/homeassistant/components/huawei_lte/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "No es un dispositivo Huawei LTE" + "not_huawei_lte": "No es un dispositivo Huawei LTE", + "reauth_successful": "La autenticaci\u00f3n se volvi\u00f3 a realizar correctamente" }, "error": { "connection_timeout": "Tiempo de espera de la conexi\u00f3n superado", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + }, + "description": "Introduce las credenciales de acceso del dispositivo.", + "title": "Volver a autenticar la integraci\u00f3n" + }, "user": { "data": { "password": "Contrase\u00f1a", diff --git a/homeassistant/components/huawei_lte/translations/et.json b/homeassistant/components/huawei_lte/translations/et.json index 08fcca84b23e319ac8e7c783e86495bf256bd668..82c36cf54d97adfe34546ca0cfb64a8de332d37b 100644 --- a/homeassistant/components/huawei_lte/translations/et.json +++ b/homeassistant/components/huawei_lte/translations/et.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Pole Huawei LTE seade" + "not_huawei_lte": "Pole Huawei LTE seade", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "connection_timeout": "\u00dchenduse ajal\u00f5pp", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Sisesta seadme juurdep\u00e4\u00e4suload.", + "title": "Taastuvasta sidumine" + }, "user": { "data": { "password": "Salas\u00f5na", diff --git a/homeassistant/components/huawei_lte/translations/fr.json b/homeassistant/components/huawei_lte/translations/fr.json index 117514985c9a6c6f7cff1af8d60c4ed7d44c6181..e6cddfe0063c6df639a7ff2538c0a00f73309bb8 100644 --- a/homeassistant/components/huawei_lte/translations/fr.json +++ b/homeassistant/components/huawei_lte/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Pas un appareil Huawei LTE" + "not_huawei_lte": "Pas un appareil Huawei LTE", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "connection_timeout": "D\u00e9lai de connexion d\u00e9pass\u00e9", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Saisissez les informations d'identification permettant d'acc\u00e9der \u00e0 l'appareil.", + "title": "R\u00e9-authentifier l'int\u00e9gration" + }, "user": { "data": { "password": "Mot de passe", diff --git a/homeassistant/components/huawei_lte/translations/he.json b/homeassistant/components/huawei_lte/translations/he.json index daad7429a83ad3465552a79707ecf14af125cbaa..8fbb6f735a8d5e0f4598e7a7a36aeda66aeb33fe 100644 --- a/homeassistant/components/huawei_lte/translations/he.json +++ b/homeassistant/components/huawei_lte/translations/he.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, "error": { "incorrect_password": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d2\u05d5\u05d9\u05d4", "incorrect_username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9 \u05e9\u05d2\u05d5\u05d9", @@ -8,6 +11,13 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + }, + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", diff --git a/homeassistant/components/huawei_lte/translations/hu.json b/homeassistant/components/huawei_lte/translations/hu.json index e1a2640539653ba56c289ea4d9e2da321d907c34..34653dcfb72d7530cf407c59065c220607308d60 100644 --- a/homeassistant/components/huawei_lte/translations/hu.json +++ b/homeassistant/components/huawei_lte/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Nem Huawei LTE eszk\u00f6z" + "not_huawei_lte": "Nem Huawei LTE eszk\u00f6z", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { "connection_timeout": "Kapcsolat id\u0151t\u00fall\u00e9p\u00e9s", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Adja meg az eszk\u00f6z hozz\u00e1f\u00e9r\u00e9si hiteles\u00edt\u0151 adatait.", + "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, "user": { "data": { "password": "Jelsz\u00f3", diff --git a/homeassistant/components/huawei_lte/translations/id.json b/homeassistant/components/huawei_lte/translations/id.json index ac925d0b46006cc0ad9d5a5f6ed4abe499519842..5bb08d626d03d93c500f7839e078f2686b94e502 100644 --- a/homeassistant/components/huawei_lte/translations/id.json +++ b/homeassistant/components/huawei_lte/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Bukan perangkat Huawei LTE" + "not_huawei_lte": "Bukan perangkat Huawei LTE", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "connection_timeout": "Tenggang waktu terhubung habis", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + }, + "description": "Masukkan kredensial akses perangkat.", + "title": "Autentikasi Ulang Integrasi" + }, "user": { "data": { "password": "Kata Sandi", diff --git a/homeassistant/components/huawei_lte/translations/it.json b/homeassistant/components/huawei_lte/translations/it.json index 6a90b67d3078c17582cd4419099b9e466af8d524..9db6aea063a638ac61591f93454a07e24e13c2c2 100644 --- a/homeassistant/components/huawei_lte/translations/it.json +++ b/homeassistant/components/huawei_lte/translations/it.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Non \u00e8 un dispositivo Huawei LTE" + "not_huawei_lte": "Non \u00e8 un dispositivo Huawei LTE", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "connection_timeout": "Timeout di connessione", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Immettere le credenziali di accesso al dispositivo.", + "title": "Autentica nuovamente l'integrazione" + }, "user": { "data": { "password": "Password", diff --git a/homeassistant/components/huawei_lte/translations/ja.json b/homeassistant/components/huawei_lte/translations/ja.json index c3b41fbbcfc510d11875d5655d7fcf7a6ba9faf3..25cf9d1b0e8e6c3c776dc05ba808d96b005244e7 100644 --- a/homeassistant/components/huawei_lte/translations/ja.json +++ b/homeassistant/components/huawei_lte/translations/ja.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Huawei LTE\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093" + "not_huawei_lte": "Huawei LTE\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" }, "error": { "connection_timeout": "\u63a5\u7d9a\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + }, + "description": "\u30c7\u30d0\u30a4\u30b9\u306e\u30a2\u30af\u30bb\u30b9\u8a8d\u8a3c\u60c5\u5831\u3092\u5165\u529b\u3057\u307e\u3059\u3002", + "title": "\u7d71\u5408\u306e\u518d\u8a8d\u8a3c" + }, "user": { "data": { "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", diff --git a/homeassistant/components/huawei_lte/translations/nb.json b/homeassistant/components/huawei_lte/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..c876e9ae4bdab503a10a2e80f7270a2f34fd7fb9 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/nb.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/nl.json b/homeassistant/components/huawei_lte/translations/nl.json index 3e5148170a7611e0edeb951781c54d71e0369e62..6fa91431fd0a024b62b997336c0387ec0f691d69 100644 --- a/homeassistant/components/huawei_lte/translations/nl.json +++ b/homeassistant/components/huawei_lte/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Geen Huawei LTE-apparaat" + "not_huawei_lte": "Geen Huawei LTE-apparaat", + "reauth_successful": "Herauthenticatie geslaagd" }, "error": { "connection_timeout": "Time-out van de verbinding", @@ -15,6 +16,13 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "title": "Integratie herauthenticeren" + }, "user": { "data": { "password": "Wachtwoord", diff --git a/homeassistant/components/huawei_lte/translations/no.json b/homeassistant/components/huawei_lte/translations/no.json index d1cd33ce2b0870e664d287098839220615192229..4261d2af9b26be38074ccdcb7c6e59a368b5fc43 100644 --- a/homeassistant/components/huawei_lte/translations/no.json +++ b/homeassistant/components/huawei_lte/translations/no.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Ikke en Huawei LTE-enhet" + "not_huawei_lte": "Ikke en Huawei LTE-enhet", + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "connection_timeout": "Tilkoblingsavbrudd", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Angi legitimasjon for enhetstilgang.", + "title": "Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "password": "Passord", diff --git a/homeassistant/components/huawei_lte/translations/pl.json b/homeassistant/components/huawei_lte/translations/pl.json index 76e7c92a01037009c9692c0cf3440308afbf1c1e..1f66183a762db43887d18628ae56c79912ee2123 100644 --- a/homeassistant/components/huawei_lte/translations/pl.json +++ b/homeassistant/components/huawei_lte/translations/pl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "To nie jest urz\u0105dzenie Huawei LTE" + "not_huawei_lte": "To nie jest urz\u0105dzenie Huawei LTE", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { "connection_timeout": "Przekroczono limit czasu pr\u00f3by po\u0142\u0105czenia", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce dost\u0119pu do urz\u0105dzenia.", + "title": "Ponownie uwierzytelnij integracj\u0119" + }, "user": { "data": { "password": "Has\u0142o", diff --git a/homeassistant/components/huawei_lte/translations/pt-BR.json b/homeassistant/components/huawei_lte/translations/pt-BR.json index a92c2de3f10ba1720eab85ac9f3353367507ea22..d10fb60a013cf67e0e01376ed3accc71a45ea4ae 100644 --- a/homeassistant/components/huawei_lte/translations/pt-BR.json +++ b/homeassistant/components/huawei_lte/translations/pt-BR.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "N\u00e3o \u00e9 um dispositivo Huawei LTE" + "not_huawei_lte": "N\u00e3o \u00e9 um dispositivo Huawei LTE", + "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" }, "error": { "connection_timeout": "Tempo limite de conex\u00e3o atingido", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Senha", + "username": "Usu\u00e1rio" + }, + "description": "Insira as credenciais de acesso ao dispositivo.", + "title": "Reautenticar Integra\u00e7\u00e3o" + }, "user": { "data": { "password": "Senha", diff --git a/homeassistant/components/huawei_lte/translations/ru.json b/homeassistant/components/huawei_lte/translations/ru.json index 6c27673b5565c3b3bae0c27e35a432500c877ee1..feb6209cc810eaba0405a7418b7e0d7d4ee8d70f 100644 --- a/homeassistant/components/huawei_lte/translations/ru.json +++ b/homeassistant/components/huawei_lte/translations/ru.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Huawei LTE" + "not_huawei_lte": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Huawei LTE", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { "connection_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u043b\u044f \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u044c", diff --git a/homeassistant/components/huawei_lte/translations/sv.json b/homeassistant/components/huawei_lte/translations/sv.json index b58dcb07da2608ec0547e6f50ec0da400eb67ed5..96317c055455e13547247a413af88e80f69ac8cb 100644 --- a/homeassistant/components/huawei_lte/translations/sv.json +++ b/homeassistant/components/huawei_lte/translations/sv.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Inte en Huawei LTE-enhet" + "not_huawei_lte": "Inte en Huawei LTE-enhet", + "reauth_successful": "\u00c5terautentisering lyckades" }, "error": { "connection_timeout": "Timeout f\u00f6r anslutning", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + }, + "description": "Logga in p\u00e5 enheten", + "title": "\u00c5terautentisera integrationen." + }, "user": { "data": { "password": "L\u00f6senord", diff --git a/homeassistant/components/huawei_lte/translations/tr.json b/homeassistant/components/huawei_lte/translations/tr.json index 6d231efa8ed990ba59a636d2db86c08f03c3e243..c2791808f6545e8ba2a82bf2fd1ce3df4d4f5b6d 100644 --- a/homeassistant/components/huawei_lte/translations/tr.json +++ b/homeassistant/components/huawei_lte/translations/tr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "Huawei LTE cihaz\u0131 de\u011fil" + "not_huawei_lte": "Huawei LTE cihaz\u0131 de\u011fil", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { "connection_timeout": "Ba\u011flant\u0131 zamana\u015f\u0131m\u0131", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Cihaz eri\u015fim kimlik bilgilerini girin.", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, "user": { "data": { "password": "Parola", diff --git a/homeassistant/components/huawei_lte/translations/zh-Hans.json b/homeassistant/components/huawei_lte/translations/zh-Hans.json index c04409b278375f025c8d6e06ed25a1cce8034cf6..e229f8f28a40d3356544421a2db1e6188e5d4414 100644 --- a/homeassistant/components/huawei_lte/translations/zh-Hans.json +++ b/homeassistant/components/huawei_lte/translations/zh-Hans.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "\u8be5\u8bbe\u5907\u4e0d\u662f\u534e\u4e3a LTE \u8bbe\u5907" + "not_huawei_lte": "\u8be5\u8bbe\u5907\u4e0d\u662f\u534e\u4e3a LTE \u8bbe\u5907", + "reauth_successful": "\u91cd\u65b0\u8ba4\u8bc1\u6210\u529f" }, "error": { "connection_timeout": "\u8fde\u63a5\u8d85\u65f6", @@ -14,6 +15,14 @@ "unknown": "\u672a\u77e5\u9519\u8bef" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7528\u6237\u540d" + }, + "description": "\u8bf7\u8f93\u5165\u8bbe\u5907\u8ba4\u8bc1\u51ed\u636e\u3002", + "title": "\u8bf7\u91cd\u65b0\u8ba4\u8bc1\u6b64\u96c6\u6210" + }, "user": { "data": { "url": "\u4e3b\u673a\u5730\u5740", diff --git a/homeassistant/components/huawei_lte/translations/zh-Hant.json b/homeassistant/components/huawei_lte/translations/zh-Hant.json index 85a2d84f6de682efb7112bec43d07abba3ea5d68..df014095c9026ee80e19eb57af763a607489d5df 100644 --- a/homeassistant/components/huawei_lte/translations/zh-Hant.json +++ b/homeassistant/components/huawei_lte/translations/zh-Hant.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_huawei_lte": "\u4e26\u975e\u83ef\u70ba LTE \u88dd\u7f6e" + "not_huawei_lte": "\u4e26\u975e\u83ef\u70ba LTE \u88dd\u7f6e", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "connection_timeout": "\u9023\u7dda\u903e\u6642", @@ -15,6 +16,14 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8f38\u5165\u88dd\u7f6e\u5b58\u53d6\u6191\u8b49\u3002", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, "user": { "data": { "password": "\u5bc6\u78bc", diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 3854b861c9821fd4fe0baee25fba4de82c63268c..d726f773b9b5e241e2c6d2839f60fc12bacf38b7 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -25,5 +25,6 @@ "codeowners": ["@balloob", "@marcelveldt"], "quality_scale": "platinum", "iot_class": "local_push", + "integration_type": "hub", "loggers": ["aiohue"] } diff --git a/homeassistant/components/hue/translations/nb.json b/homeassistant/components/hue/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..25f95a55ac61f002e74b7471357547dff96c9052 --- /dev/null +++ b/homeassistant/components/hue/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "linking": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/translations/pl.json b/homeassistant/components/hue/translations/pl.json index ff8ab182dd4e4be8d43e544e3ac9453c3d6c67ad..9cbe70d868b3cc55cf4aed9b6d05f74ece31653e 100644 --- a/homeassistant/components/hue/translations/pl.json +++ b/homeassistant/components/hue/translations/pl.json @@ -55,15 +55,15 @@ }, "trigger_type": { "double_short_release": "przycisk \"{subtype}\" zostanie zwolniony", - "initial_press": "przycisk \"{subtype}\" zostanie lekko naci\u015bni\u0119ty", - "long_release": "przycisk \"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu", - "remote_button_long_release": "przycisk \"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu", - "remote_button_short_press": "przycisk \"{subtype}\" zostanie naci\u015bni\u0119ty", - "remote_button_short_release": "przycisk \"{subtype}\" zostanie zwolniony", + "initial_press": "\"{subtype}\" zostanie lekko naci\u015bni\u0119ty", + "long_release": "\"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu", + "remote_button_long_release": "\"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu", + "remote_button_short_press": "\"{subtype}\" zostanie naci\u015bni\u0119ty", + "remote_button_short_release": "\"{subtype}\" zostanie zwolniony", "remote_double_button_long_press": "oba przyciski \"{subtype}\" zostan\u0105 zwolnione po d\u0142ugim naci\u015bni\u0119ciu", "remote_double_button_short_press": "oba przyciski \"{subtype}\" zostan\u0105 zwolnione", - "repeat": "przycisk \"{subtype}\" zostanie przytrzymany", - "short_release": "przycisk \"{subtype}\" zostanie zwolniony po kr\u00f3tkim naci\u015bni\u0119ciu", + "repeat": "\"{subtype}\" zostanie przytrzymany", + "short_release": "\"{subtype}\" zostanie zwolniony po kr\u00f3tkim naci\u015bni\u0119ciu", "start": "\"{subtype}\" zostanie lekko naci\u015bni\u0119ty" } }, diff --git a/homeassistant/components/hue/v1/device_trigger.py b/homeassistant/components/hue/v1/device_trigger.py index 4316ea65406858d07b17550f1c49ae839ab7eca7..e3639e802da05aa5e5d2be293073cf8b322a26cb 100644 --- a/homeassistant/components/hue/v1/device_trigger.py +++ b/homeassistant/components/hue/v1/device_trigger.py @@ -1,4 +1,6 @@ """Provides device automations for Philips Hue events in V1 bridge/api.""" +from __future__ import annotations + from typing import TYPE_CHECKING import voluptuous as vol @@ -173,9 +175,7 @@ async def async_attach_trigger( @callback -def async_get_triggers( - bridge: "HueBridge", device: DeviceEntry -) -> list[dict[str, str]]: +def async_get_triggers(bridge: HueBridge, device: DeviceEntry) -> list[dict[str, str]]: """Return device triggers for device on `v1` bridge. Make sure device is a supported remote model. diff --git a/homeassistant/components/huisbaasje/translations/bg.json b/homeassistant/components/huisbaasje/translations/bg.json index 67a484573aa0c1f968b9505262a252acfdfe528f..059e100270fa7745da6d9906b3d76b5f17c5d3ff 100644 --- a/homeassistant/components/huisbaasje/translations/bg.json +++ b/homeassistant/components/huisbaasje/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/huisbaasje/translations/nb.json b/homeassistant/components/huisbaasje/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/huisbaasje/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index f7c8cd1eb4b4f81d9f248e41fdb935a7ae10a228..4b0d666d2aecb118eef3226c800f07430d2ed18b 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -42,7 +42,13 @@ PARALLEL_UPDATES = 1 CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SCENE, Platform.SENSOR] +PLATFORMS = [ + Platform.BUTTON, + Platform.COVER, + Platform.SCENE, + Platform.SELECT, + Platform.SENSOR, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index 483e2ca2784aa0603de57f238d8e382853b2e8ab..ca9a72a7b990b26475c7b3c3927d9df02418ede9 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -48,6 +48,13 @@ BUTTONS: Final = [ entity_category=EntityCategory.DIAGNOSTIC, press_action=lambda shade: shade.jog(), ), + PowerviewButtonDescription( + key="favorite", + name="Favorite", + icon="mdi:heart", + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda shade: shade.favorite(), + ), ] diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py index 9d99710f36da9a6ca6bc883bae44c2e6bf6b2da9..7dd4c229c486f752236e88767bc1daf01867920c 100644 --- a/homeassistant/components/hunterdouglas_powerview/const.py +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -46,6 +46,9 @@ ROOM_ID = "id" SHADE_BATTERY_LEVEL = "batteryStrength" SHADE_BATTERY_LEVEL_MAX = 200 +ATTR_SIGNAL_STRENGTH = "signalStrength" +ATTR_SIGNAL_STRENGTH_MAX = 4 + STATE_ATTRIBUTE_ROOM_NAME = "roomName" HUB_EXCEPTIONS = ( @@ -80,3 +83,10 @@ ATTR_BATTERY_KIND = "batteryKind" BATTERY_KIND_HARDWIRED = 1 BATTERY_KIND_BATTERY = 2 BATTERY_KIND_RECHARGABLE = 3 + +POWER_SUPPLY_TYPE_MAP = { + BATTERY_KIND_HARDWIRED: "Hardwired Power Supply", + BATTERY_KIND_BATTERY: "Battery Wand", + BATTERY_KIND_RECHARGABLE: "Rechargeable Battery", +} +POWER_SUPPLY_TYPE_REVERSE_MAP = {v: k for k, v in POWER_SUPPLY_TYPE_MAP.items()} diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py index 7c45feba491155ee975a192256570ebf32faa04c..203aea6c49f57bf6fe7262cad553f8fbde3b4fa1 100644 --- a/homeassistant/components/hunterdouglas_powerview/coordinator.py +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -25,7 +25,7 @@ class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]) shades: Shades, hub_address: str, ) -> None: - """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" + """Initialize DataUpdateCoordinator to gather data for specific Powerview Hub.""" self.shades = shades super().__init__( hass, diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index d444354b7a86dd9f4d4064080ecf3fe7a1f89b6a..347b2c3af0312a79cfd7043426f72d424d03e810 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -6,22 +6,19 @@ from collections.abc import Iterable from contextlib import suppress from datetime import timedelta import logging +from math import ceil 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, - ShadeTopDownBottomUp, - factory as PvShade, ) +from aiopvapi.resources.shade import BaseShade, factory as PvShade import async_timeout from homeassistant.components.cover import ( @@ -107,32 +104,6 @@ async def async_setup_entry( async_add_entities(entities) -def create_powerview_shade_entity( - coordinator: PowerviewShadeUpdateCoordinator, - device_info: PowerviewDeviceInfo, - room_name: str, - shade: BaseShade, - name_before_refresh: str, -) -> Iterable[ShadeEntity]: - """Create a PowerViewShade entity.""" - - classes: list[BaseShade] = [] - if isinstance(shade, ShadeTopDownBottomUp): - classes.extend([PowerViewShadeTDBUTop, PowerViewShadeTDBUBottom]) - elif ( # this will be extended further in next release for more defined control - shade.capability.capabilities.tiltOnClosed - or shade.capability.capabilities.tiltAnywhere - ): - classes.append(PowerViewShadeWithTilt) - else: - classes.append(PowerViewShade) - _LOGGER.debug("%s (%s) detected as %a", shade.name, shade.capability.type, classes) - return [ - cls(coordinator, device_info, room_name, shade, name_before_refresh) - for cls in classes - ] - - 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) @@ -392,6 +363,237 @@ class PowerViewShade(PowerViewShadeBase): ) +class PowerViewShadeWithTiltBase(PowerViewShade): + """Representation for PowerView shades with tilt capabilities.""" + + 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_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + if self._device_info.model != LEGACY_DEVICE_MODEL: + self._attr_supported_features |= CoverEntityFeature.STOP_TILT + self._max_tilt = self._shade.shade_limits.tilt_max + + @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_tilt_position(self) -> PowerviewShadeMove: + """Return the open tilt position and required additional positions.""" + return PowerviewShadeMove(self._shade.open_position_tilt, {}) + + @property + def close_tilt_position(self) -> PowerviewShadeMove: + """Return the close tilt position and required additional positions.""" + return PowerviewShadeMove(self._shade.close_position_tilt, {}) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + 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_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() + + 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_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 _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}, {} + ) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilting.""" + await self.async_stop_cover() + + +class PowerViewShadeWithTiltOnClosed(PowerViewShadeWithTiltBase): + """Representation of a PowerView shade with tilt when closed capabilities. + + API Class: ShadeBottomUpTiltOnClosed + ShadeBottomUpTiltOnClosed90 + + Type 1 - Bottom Up w/ 90° Tilt + Shade 44 - a shade thought to have been a firmware issue (type 0 usually dont 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.""" + return PowerviewShadeMove( + self._shade.open_position_tilt, {POS_KIND_PRIMARY: MIN_POSITION} + ) + + @property + def close_tilt_position(self) -> PowerviewShadeMove: + """Return the close tilt position and required additional positions.""" + return PowerviewShadeMove( + self._shade.close_position_tilt, {POS_KIND_PRIMARY: MIN_POSITION} + ) + + @callback + 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}, + ) + + +class PowerViewShadeWithTiltAnywhere(PowerViewShadeWithTiltBase): + """Representation of a PowerView shade with tilt anywhere capabilities. + + API Class: ShadeBottomUpTiltAnywhere, ShadeVerticalTiltAnywhere + + Type 2 - Bottom Up w/ 180° Tilt + Type 4 - Vertical (Traversing) w/ 180° Tilt + """ + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) + position_vane = self.positions.vane + return PowerviewShadeMove( + { + ATTR_POSITION1: position_shade, + ATTR_POSITION2: position_vane, + ATTR_POSKIND1: POS_KIND_PRIMARY, + ATTR_POSKIND2: POS_KIND_VANE, + }, + {}, + ) + + @callback + def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: + """Return a PowerviewShadeMove.""" + position_shade = self.positions.primary + position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) + return PowerviewShadeMove( + { + ATTR_POSITION1: position_shade, + ATTR_POSITION2: position_vane, + ATTR_POSKIND1: POS_KIND_PRIMARY, + ATTR_POSKIND2: POS_KIND_VANE, + }, + {}, + ) + + +class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase): + """Representation of a shade with tilt only capability, no move. + + API Class: ShadeTiltOnly + + Type 5 - Tilt Only 180° + """ + + 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_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + if self._device_info.model != LEGACY_DEVICE_MODEL: + self._attr_supported_features |= CoverEntityFeature.STOP_TILT + self._max_tilt = self._shade.shade_limits.tilt_max + + +class PowerViewShadeTopDown(PowerViewShade): + """Representation of a shade that lowers from the roof to the floor. + + These shades are inverted where MAX_POSITION equates to closed and MIN_POSITION is open + API Class: ShadeTopDown + + Type 6 - Top Down + """ + + @property + def current_cover_position(self) -> int: + """Return the current position of cover.""" + return hd_position_to_hass(MAX_POSITION - self.positions.primary, MAX_POSITION) + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + return (MAX_POSITION - self.positions.primary) <= CLOSED_POSITION + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the shade to a specific position.""" + await self._async_set_cover_position(100 - kwargs[ATTR_POSITION]) + + class PowerViewShadeDualRailBase(PowerViewShade): """Representation of a shade with top/down bottom/up capabilities. @@ -528,9 +730,60 @@ class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): ) -class PowerViewShadeWithTilt(PowerViewShade): - """Representation of a PowerView shade with tilt capabilities.""" +class PowerViewShadeDualOverlappedBase(PowerViewShade): + """Represent a shade that has a front sheer and rear opaque panel. + This equates to two shades being controlled by one motor + """ + + @property + def transition_steps(self) -> int: + """Return the steps to make a move.""" + # poskind 1 represents the second half of the shade in hass + # front must be fully closed before rear can move + # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades + primary = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + # poskind 2 represents the shade first half of the shade in hass + # rear (opaque) must be fully open before front can move + # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades + secondary = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 + return ceil(primary + secondary) + + @property + def open_position(self) -> PowerviewShadeMove: + """Return the open position and required additional positions.""" + return PowerviewShadeMove( + { + ATTR_POSITION1: MAX_POSITION, + ATTR_POSKIND1: POS_KIND_PRIMARY, + }, + {POS_KIND_SECONDARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + ) + + @property + def close_position(self) -> PowerviewShadeMove: + """Return the open position and required additional positions.""" + return PowerviewShadeMove( + { + ATTR_POSITION1: MIN_POSITION, + ATTR_POSKIND1: POS_KIND_SECONDARY, + }, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + ) + + +class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): + """Represent a shade that has a front sheer and rear opaque panel. + + This equates to two shades being controlled by one motor. + The front shade must be completely down before the rear shade will move. + Sibling Class: PowerViewShadeDualOverlappedFront, PowerViewShadeDualOverlappedRear + API Class: ShadeDualOverlapped + + Type 8 - Duolite (front and rear shades) + """ + + # type def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -541,104 +794,312 @@ class PowerViewShadeWithTilt(PowerViewShade): ) -> None: """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) - self._attr_supported_features |= ( - CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.SET_TILT_POSITION - ) - if self._device_info.model != LEGACY_DEVICE_MODEL: - self._attr_supported_features |= CoverEntityFeature.STOP_TILT - self._max_tilt = self._shade.shade_limits.tilt_max + self._attr_unique_id = f"{self._shade.id}_combined" + self._attr_name = f"{self._shade_name} Combined" @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) + def is_closed(self) -> bool: + """Return if the cover is closed.""" + # if rear shade is down it is closed + return self.positions.secondary <= CLOSED_POSITION @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) + def current_cover_position(self) -> int: + """Return the current position of cover.""" + # if front is open return that (other positions are impossible) + # if front shade is closed get position of rear + position = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + if self.positions.primary == MIN_POSITION: + position = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 + + return ceil(position) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) + # note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional + # override is required for differences between type 8/9/10 + # this just stores the value in the coordinator for future reference + if target_hass_position <= 50: + target_hass_position = target_hass_position * 2 + return PowerviewShadeMove( + { + ATTR_POSITION1: position_shade, + ATTR_POSKIND1: POS_KIND_SECONDARY, + }, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + ) + + # 51 <= target_hass_position <= 100 (51-100 represents front sheer shade) + target_hass_position = (target_hass_position - 50) * 2 + return PowerviewShadeMove( + { + ATTR_POSITION1: position_shade, + ATTR_POSKIND1: POS_KIND_PRIMARY, + }, + {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + ) + + +class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): + """Represent the shade front panel - These have a opaque panel too. + + This equates to two shades being controlled by one motor. + The front shade must be completely down before the rear shade will move. + Sibling Class: PowerViewShadeDualOverlappedCombined, PowerViewShadeDualOverlappedRear + API Class: ShadeDualOverlapped + ShadeDualOverlappedTilt90 + ShadeDualOverlappedTilt180 + + Type 8 - Duolite (front and rear shades) + Type 9 - Duolite with 90° Tilt (front bottom up shade that also tilts plus a rear opaque (non-tilting) shade) + Type 10 - Duolite with 180° Tilt + """ + + 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}_front" + self._attr_name = f"{self._shade_name} Front" @property - def open_position(self) -> PowerviewShadeMove: - """Return the open position and required additional positions.""" + def should_poll(self) -> bool: + """Certain shades create multiple entities. + + Do not poll shade multiple times. Combined shade will return data + and multiple polling will cause timeouts. + """ + return False + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) + # note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional + # override is required for differences between type 8/9/10 + # this just stores the value in the coordinator for future reference return PowerviewShadeMove( - self._shade.open_position, {POS_KIND_VANE: MIN_POSITION} + { + ATTR_POSITION1: position_shade, + ATTR_POSKIND1: POS_KIND_PRIMARY, + }, + {POS_KIND_SECONDARY: MAX_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} + { + ATTR_POSITION1: MIN_POSITION, + ATTR_POSKIND1: POS_KIND_PRIMARY, + }, + {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, ) + +class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase): + """Represent the shade front panel - These have a opaque panel too. + + This equates to two shades being controlled by one motor. + The front shade must be completely down before the rear shade will move. + Sibling Class: PowerViewShadeDualOverlappedCombined, PowerViewShadeDualOverlappedFront + API Class: ShadeDualOverlapped + ShadeDualOverlappedTilt90 + ShadeDualOverlappedTilt180 + + Type 8 - Duolite (front and rear shades) + Type 9 - Duolite with 90° Tilt (front bottom up shade that also tilts plus a rear opaque (non-tilting) shade) + Type 10 - Duolite with 180° Tilt + """ + + 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}_rear" + self._attr_name = f"{self._shade_name} Rear" + @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 + def should_poll(self) -> bool: + """Certain shades create multiple entities. + + Do not poll shade multiple times. Combined shade will return data + and multiple polling will cause timeouts. + """ + return False + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + # if rear shade is down it is closed + return self.positions.secondary <= CLOSED_POSITION + + @property + def current_cover_position(self) -> int: + """Return the current position of cover.""" + return hd_position_to_hass(self.positions.secondary, MAX_POSITION) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) + # note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional + # override is required for differences between type 8/9/10 + # this just stores the value in the coordinator for future reference return PowerviewShadeMove( - {ATTR_POSKIND1: POS_KIND_VANE, ATTR_POSITION1: self._max_tilt}, - {POS_KIND_PRIMARY: MIN_POSITION}, + { + ATTR_POSITION1: position_shade, + ATTR_POSKIND1: POS_KIND_SECONDARY, + }, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: 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 + def open_position(self) -> PowerviewShadeMove: + """Return the open position and required additional positions.""" return PowerviewShadeMove( - {ATTR_POSKIND1: POS_KIND_VANE, ATTR_POSITION1: MIN_POSITION}, - {POS_KIND_PRIMARY: MIN_POSITION}, + { + ATTR_POSITION1: MAX_POSITION, + ATTR_POSKIND1: POS_KIND_SECONDARY, + }, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, ) - async def async_close_cover_tilt(self, **kwargs: Any) -> None: - """Close the cover tilt.""" - 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_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() +class PowerViewShadeDualOverlappedCombinedTilt(PowerViewShadeDualOverlappedCombined): + """Represent a shade that has a front sheer and rear opaque panel. - 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]) + This equates to two shades being controlled by one motor. + The front shade must be completely down before the rear shade will move. + Tilting this shade will also force positional change of the main roller. - async def _async_set_cover_tilt_position( - self, target_hass_tilt_position: int + Sibling Class: PowerViewShadeDualOverlappedFront, PowerViewShadeDualOverlappedRear + API Class: ShadeDualOverlappedTilt90 + ShadeDualOverlappedTilt180 + + Type 9 - Duolite with 90° Tilt (front bottom up shade that also tilts plus a rear opaque (non-tilting) shade) + Type 10 - Duolite with 180° Tilt + """ + + # type + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, ) -> 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) + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_supported_features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION ) - await self._async_execute_move(self._get_shade_tilt(target_hass_tilt_position)) - self.async_write_ha_state() + if self._device_info.model != LEGACY_DEVICE_MODEL: + self._attr_supported_features |= CoverEntityFeature.STOP_TILT + self._max_tilt = self._shade.shade_limits.tilt_max - @callback - 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}, - ) + @property + def transition_steps(self) -> int: + """Return the steps to make a move.""" + # poskind 1 represents the second half of the shade in hass + # front must be fully closed before rear can move + # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades + primary = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + # poskind 2 represents the shade first half of the shade in hass + # rear (opaque) must be fully open before front can move + # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades + secondary = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 + vane = hd_position_to_hass(self.positions.vane, self._max_tilt) + return ceil(primary + secondary + vane) @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}, + { + ATTR_POSITION1: position_vane, + ATTR_POSKIND1: POS_KIND_VANE, + }, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, ) - async def async_stop_cover_tilt(self, **kwargs: Any) -> None: - """Stop the cover tilting.""" - await self.async_stop_cover() + @property + def open_tilt_position(self) -> PowerviewShadeMove: + """Return the open tilt position and required additional positions.""" + return PowerviewShadeMove( + self._shade.open_position_tilt, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, + ) + + @property + def close_tilt_position(self) -> PowerviewShadeMove: + """Return the open tilt position and required additional positions.""" + return PowerviewShadeMove( + self._shade.open_position_tilt, + {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, + ) + + +TYPE_TO_CLASSES = { + 0: (PowerViewShade,), + 1: (PowerViewShadeWithTiltOnClosed,), + 2: (PowerViewShadeWithTiltAnywhere,), + 3: (PowerViewShade,), + 4: (PowerViewShadeWithTiltAnywhere,), + 5: (PowerViewShadeTiltOnly,), + 6: (PowerViewShadeTopDown,), + 7: ( + PowerViewShadeTDBUTop, + PowerViewShadeTDBUBottom, + ), + 8: ( + PowerViewShadeDualOverlappedCombined, + PowerViewShadeDualOverlappedFront, + PowerViewShadeDualOverlappedRear, + ), + 9: ( + PowerViewShadeDualOverlappedCombinedTilt, + PowerViewShadeDualOverlappedFront, + PowerViewShadeDualOverlappedRear, + ), + 10: ( + PowerViewShadeDualOverlappedCombinedTilt, + PowerViewShadeDualOverlappedFront, + PowerViewShadeDualOverlappedRear, + ), +} + + +def create_powerview_shade_entity( + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name_before_refresh: str, +) -> Iterable[ShadeEntity]: + """Create a PowerViewShade entity.""" + classes: Iterable[BaseShade] = TYPE_TO_CLASSES.get( + shade.capability.type, (PowerViewShade,) + ) + _LOGGER.debug( + "%s (%s) detected as %a %s", + shade.name, + shade.capability.type, + classes, + shade.raw_data, + ) + return [ + cls(coordinator, device_info, room_name, shade, name_before_refresh) + for cls in classes + ] diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json index 930e80733e0fb963669e796eed60cd8c34a62c14..15b10dca0e0db809b61e11039281eb648206e225 100644 --- a/homeassistant/components/hunterdouglas_powerview/manifest.json +++ b/homeassistant/components/hunterdouglas_powerview/manifest.json @@ -2,7 +2,7 @@ "domain": "hunterdouglas_powerview", "name": "Hunter Douglas PowerView", "documentation": "https://www.home-assistant.io/integrations/hunterdouglas_powerview", - "requirements": ["aiopvapi==2.0.2"], + "requirements": ["aiopvapi==2.0.3"], "codeowners": ["@bdraco", "@kingy444", "@trullock"], "config_flow": true, "homekit": { @@ -17,8 +17,5 @@ ], "zeroconf": ["_powerview._tcp.local."], "iot_class": "local_polling", - "loggers": ["aiopvapi"], - "supported_brands": { - "luxaflex": "Luxaflex" - } + "loggers": ["aiopvapi"] } diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py new file mode 100644 index 0000000000000000000000000000000000000000..e440d0b7d27c0f474c7a2ef2185505760b358873 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -0,0 +1,121 @@ +"""Support for hunterdouglass_powerview settings.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, Final + +from aiopvapi.resources.shade import BaseShade, factory as PvShade + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +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 ( + ATTR_BATTERY_KIND, + DOMAIN, + POWER_SUPPLY_TYPE_MAP, + POWER_SUPPLY_TYPE_REVERSE_MAP, + ROOM_ID_IN_SHADE, + ROOM_NAME_UNICODE, + SHADE_BATTERY_LEVEL, +) +from .coordinator import PowerviewShadeUpdateCoordinator +from .entity import ShadeEntity +from .model import PowerviewDeviceInfo, PowerviewEntryData + + +@dataclass +class PowerviewSelectDescriptionMixin: + """Mixin to describe a select entity.""" + + current_fn: Callable[[BaseShade], Any] + select_fn: Callable[[BaseShade, str], Coroutine[Any, Any, bool]] + + +@dataclass +class PowerviewSelectDescription( + SelectEntityDescription, PowerviewSelectDescriptionMixin +): + """A class that describes select entities.""" + + entity_category: EntityCategory = EntityCategory.CONFIG + + +DROPDOWNS: Final = [ + PowerviewSelectDescription( + key="powersource", + name="Power Source", + icon="mdi:power-plug-outline", + current_fn=lambda shade: POWER_SUPPLY_TYPE_MAP.get( + shade.raw_data.get(ATTR_BATTERY_KIND), None + ), + options=list(POWER_SUPPLY_TYPE_MAP.values()), + select_fn=lambda shade, option: shade.set_power_source( + POWER_SUPPLY_TYPE_REVERSE_MAP.get(option) + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the hunter douglas select entities.""" + + pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] + + entities = [] + 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 = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") + + for description in DROPDOWNS: + entities.append( + PowerViewSelect( + pv_entry.coordinator, + pv_entry.device_info, + room_name, + shade, + name_before_refresh, + description, + ) + ) + + async_add_entities(entities) + + +class PowerViewSelect(ShadeEntity, SelectEntity): + """Representation of a select entity.""" + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + description: PowerviewSelectDescription, + ) -> None: + """Initialize the select entity.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self.entity_description: PowerviewSelectDescription = description + self._attr_name = f"{self._shade_name} {description.name}" + self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return self.entity_description.current_fn(self._shade) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.select_fn(self._shade, option) + await self._shade.refresh() # force update data to ensure new info is in coordinator + self.async_write_ha_state() diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 1887498e60487b17f79f9da5738bb6b7e35f1f8d..a9ea823e0c6326f1b986ee6f51b61c508b6f8ef3 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -1,9 +1,15 @@ """Support for hunterdouglass_powerview sensors.""" + +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.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -13,67 +19,125 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ATTR_BATTERY_KIND, + ATTR_SIGNAL_STRENGTH, + ATTR_SIGNAL_STRENGTH_MAX, + BATTERY_KIND_HARDWIRED, DOMAIN, ROOM_ID_IN_SHADE, ROOM_NAME_UNICODE, SHADE_BATTERY_LEVEL, SHADE_BATTERY_LEVEL_MAX, ) +from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity -from .model import PowerviewEntryData +from .model import PowerviewDeviceInfo, PowerviewEntryData + + +@dataclass +class PowerviewSensorDescriptionMixin: + """Mixin to describe a Sensor entity.""" + + update_fn: Callable[[BaseShade], Any] + native_value_fn: Callable[[BaseShade], int] + create_sensor_fn: Callable[[BaseShade], bool] + + +@dataclass +class PowerviewSensorDescription( + SensorEntityDescription, PowerviewSensorDescriptionMixin +): + """Class to describe a Sensor entity.""" + + entity_category = EntityCategory.DIAGNOSTIC + state_class = SensorStateClass.MEASUREMENT + + +SENSORS: Final = [ + PowerviewSensorDescription( + key="charge", + name="Battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + native_value_fn=lambda shade: round( + shade.raw_data[SHADE_BATTERY_LEVEL] / SHADE_BATTERY_LEVEL_MAX * 100 + ), + create_sensor_fn=lambda shade: bool( + shade.raw_data.get(ATTR_BATTERY_KIND) != BATTERY_KIND_HARDWIRED + and SHADE_BATTERY_LEVEL in shade.raw_data + ), + update_fn=lambda shade: shade.refresh_battery(), + ), + PowerviewSensorDescription( + key="signal", + name="Signal", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=PERCENTAGE, + native_value_fn=lambda shade: round( + shade.raw_data[ATTR_SIGNAL_STRENGTH] / ATTR_SIGNAL_STRENGTH_MAX * 100 + ), + create_sensor_fn=lambda shade: bool(ATTR_SIGNAL_STRENGTH in shade.raw_data), + update_fn=lambda shade: shade.refresh(), + entity_registry_enabled_default=False, + ), +] async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the hunter douglas shades sensors.""" + """Set up the hunter douglas sensor entities.""" pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] - entities = [] + entities: list[PowerViewSensor] = [] 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 = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - entities.append( - PowerViewShadeBatterySensor( - pv_entry.coordinator, - pv_entry.device_info, - room_name, - shade, - name_before_refresh, - ) - ) + + for description in SENSORS: + if description.create_sensor_fn(shade): + entities.append( + PowerViewSensor( + pv_entry.coordinator, + pv_entry.device_info, + room_name, + shade, + name_before_refresh, + description, + ) + ) + async_add_entities(entities) -class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): - """Representation of an shade battery charge sensor.""" +class PowerViewSensor(ShadeEntity, SensorEntity): + """Representation of an shade sensor.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_native_unit_of_measurement = PERCENTAGE - _attr_device_class = SensorDeviceClass.BATTERY - _attr_state_class = SensorStateClass.MEASUREMENT + entity_description: PowerviewSensorDescription - def __init__(self, coordinator, device_info, room_name, shade, name): - """Initialize the shade.""" + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + description: PowerviewSensorDescription, + ) -> None: + """Initialize the select entity.""" 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" + self.entity_description = description + self._attr_name = f"{self._shade_name} {description.name}" + self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" + self._attr_native_unit_of_measurement = description.native_unit_of_measurement @property def native_value(self) -> int: """Get the current value in percentage.""" - return round( - self._shade.raw_data[SHADE_BATTERY_LEVEL] / SHADE_BATTERY_LEVEL_MAX * 100 - ) + return self.entity_description.native_value_fn(self._shade) async def async_added_to_hass(self) -> None: """When entity is added to hass.""" @@ -88,5 +152,6 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): self.async_write_ha_state() async def async_update(self) -> None: - """Refresh shade battery.""" - await self._shade.refreshBattery() + """Refresh sensor entity.""" + await self.entity_description.update_fn(self._shade) + self.async_write_ha_state() diff --git a/homeassistant/components/hunterdouglas_powerview/translations/nb.json b/homeassistant/components/hunterdouglas_powerview/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hurrican_shutters_wholesale/manifest.json b/homeassistant/components/hurrican_shutters_wholesale/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..1fb5bc5600d387805aa215717c91c03f788146d9 --- /dev/null +++ b/homeassistant/components/hurrican_shutters_wholesale/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "hurrican_shutters_wholesale", + "name": "Hurrican Shutters Wholesale", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 6a2f1467b1961e4db5f99afab57dc72e7d6972e0..a99dea574476013c87d105e7c070e25406f1df9e 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -14,8 +14,8 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION 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.helpers.update_coordinator import ( @@ -55,9 +55,9 @@ async def async_setup_entry( description = elevator.get("description") if label is not None: - name = f"Elevator {label} at {station_name}" + name = f"Elevator {label}" else: - name = f"Unknown elevator at {station_name}" + name = "Unknown elevator" if description is not None: name += f" ({description})" @@ -78,7 +78,6 @@ async def async_setup_entry( "button_type": elevator.get("buttonType"), "cause": elevator.get("cause"), "lines": lines, - ATTR_ATTRIBUTION: ATTRIBUTION, }, } return elevators @@ -126,6 +125,9 @@ async def async_setup_entry( class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): """HVVDepartureBinarySensor class.""" + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + def __init__(self, coordinator, idx, config_entry): """Initialize.""" super().__init__(coordinator) @@ -150,6 +152,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): def device_info(self): """Return the device info for this sensor.""" return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, identifiers={ ( DOMAIN, diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index e2de1758dd5f98fab4da0c3f68ee055e603935ed..dfc69e5171011d1843a3385cd77bf08dbd18b65e 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle @@ -64,7 +65,8 @@ class HVVDepartureSensor(SensorEntity): self.station_name = self.config_entry.data[CONF_STATION]["name"] self._attr_extra_state_attributes = {} self._attr_available = False - self._attr_name = f"Departures at {self.station_name}" + self._attr_has_entity_name = True + self._attr_name = "Departures" self._last_error = None self.gti = hub.gti @@ -167,6 +169,7 @@ class HVVDepartureSensor(SensorEntity): def device_info(self): """Return the device info for this sensor.""" return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, identifiers={ ( DOMAIN, @@ -176,5 +179,5 @@ class HVVDepartureSensor(SensorEntity): ) }, manufacturer=MANUFACTURER, - name=self.name, + name=self.config_entry.data[CONF_STATION]["name"], ) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index d92d785fadf91fb500a142153f643330b4ee4e14..da413cce5ab29700f0b598f62ae798e9d7d6e397 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -7,7 +7,7 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from homeassistant.components import persistent_notification -from homeassistant.const import ATTR_ATTRIBUTION, CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send @@ -19,8 +19,6 @@ _LOGGER = logging.getLogger(__name__) ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] -ATTRIBUTION = "Data provided by hydrawise.com" - CONF_WATERING_TIME = "watering_minutes" NOTIFICATION_ID = "hydrawise_notification" @@ -89,6 +87,8 @@ class HydrawiseHub: class HydrawiseEntity(Entity): """Entity class for Hydrawise devices.""" + _attr_attribution = "Data provided by hydrawise.com" + def __init__(self, data, description: EntityDescription): """Initialize the Hydrawise entity.""" self.entity_description = description @@ -111,4 +111,4 @@ class HydrawiseEntity(Entity): @property def extra_state_attributes(self): """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION, "identifier": self.data.get("relay")} + return {"identifier": self.data.get("relay")} diff --git a/homeassistant/components/hyperion/translations/no.json b/homeassistant/components/hyperion/translations/no.json index 8fed4ee2437804881e4e61607b8a7054f5569d77..9fe812601c147231a2efcc2f5590a8cc8768f7d6 100644 --- a/homeassistant/components/hyperion/translations/no.json +++ b/homeassistant/components/hyperion/translations/no.json @@ -8,7 +8,7 @@ "auth_required_error": "Kan ikke fastsl\u00e5 om autorisasjon er n\u00f8dvendig", "cannot_connect": "Tilkobling mislyktes", "no_id": "Hyperion Ambilight-forekomsten rapporterte ikke ID-en", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/ialarm/translations/nb.json b/homeassistant/components/ialarm/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/ialarm/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 61f27e8970cea27e9f8d994f03ebd255baffa97f..1bd9d7d72cf9cd47df6cb69e9ce15af740164e2e 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -14,8 +14,8 @@ from iaqualink.device import ( AqualinkDevice, AqualinkLight, AqualinkSensor, + AqualinkSwitch, AqualinkThermostat, - AqualinkToggle, ) from iaqualink.exception import AqualinkServiceException from typing_extensions import Concatenate, ParamSpec @@ -29,7 +29,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -56,7 +55,9 @@ PLATFORMS = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Set up Aqualink from a config entry.""" username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] @@ -70,17 +71,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sensors = hass.data[DOMAIN][SENSOR_DOMAIN] = [] switches = hass.data[DOMAIN][SWITCH_DOMAIN] = [] - session = async_get_clientsession(hass) - aqualink = AqualinkClient(username, password, session) + aqualink = AqualinkClient(username, password) try: await aqualink.login() except AqualinkServiceException as login_exception: _LOGGER.error("Failed to login: %s", login_exception) + await aqualink.close() return False except ( asyncio.TimeoutError, aiohttp.client_exceptions.ClientConnectorError, ) as aio_exception: + await aqualink.close() raise ConfigEntryNotReady( f"Error while attempting login: {aio_exception}" ) from aio_exception @@ -88,6 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: systems = await aqualink.get_systems() except AqualinkServiceException as svc_exception: + await aqualink.close() raise ConfigEntryNotReady( f"Error while attempting to retrieve systems list: {svc_exception}" ) from svc_exception @@ -95,27 +98,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: systems = list(systems.values()) if not systems: _LOGGER.error("No systems detected or supported") + await aqualink.close() return False - # Only supporting the first system for now. - try: - devices = await systems[0].get_devices() - except AqualinkServiceException as svc_exception: - raise ConfigEntryNotReady( - f"Error while attempting to retrieve devices list: {svc_exception}" - ) from svc_exception - - for dev in devices.values(): - if isinstance(dev, AqualinkThermostat): - climates += [dev] - elif isinstance(dev, AqualinkLight): - lights += [dev] - elif isinstance(dev, AqualinkBinarySensor): - binary_sensors += [dev] - elif isinstance(dev, AqualinkSensor): - sensors += [dev] - elif isinstance(dev, AqualinkToggle): - switches += [dev] + for system in systems: + try: + devices = await system.get_devices() + except AqualinkServiceException as svc_exception: + await aqualink.close() + raise ConfigEntryNotReady( + f"Error while attempting to retrieve devices list: {svc_exception}" + ) from svc_exception + + for dev in devices.values(): + if isinstance(dev, AqualinkThermostat): + climates += [dev] + elif isinstance(dev, AqualinkLight): + lights += [dev] + elif isinstance(dev, AqualinkSwitch): + switches += [dev] + elif isinstance(dev, AqualinkBinarySensor): + binary_sensors += [dev] + elif isinstance(dev, AqualinkSensor): + sensors += [dev] platforms = [] if binary_sensors: @@ -134,23 +139,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Got %s switches: %s", len(switches), switches) platforms.append(Platform.SWITCH) + hass.data[DOMAIN]["client"] = aqualink + await hass.config_entries.async_forward_entry_setups(entry, platforms) async def _async_systems_update(now): """Refresh internal state for all systems.""" - prev = systems[0].online - - try: - await systems[0].update() - except AqualinkServiceException as svc_exception: - if prev is not None: - _LOGGER.warning("Failed to refresh iAqualink state: %s", svc_exception) - else: - cur = systems[0].online - if cur is True and prev is not True: - _LOGGER.warning("Reconnected to iAqualink") - - async_dispatcher_send(hass, DOMAIN) + for system in systems: + prev = system.online + + try: + await system.update() + except AqualinkServiceException as svc_exception: + if prev is not None: + _LOGGER.warning( + "Failed to refresh system %s state: %s", + system.serial, + svc_exception, + ) + else: + cur = system.online + if cur and not prev: + _LOGGER.warning("System %s reconnected to iAqualink", system.serial) + + async_dispatcher_send(hass, DOMAIN) async_track_time_interval(hass, _async_systems_update, UPDATE_INTERVAL) @@ -159,6 +171,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + aqualink = hass.data[DOMAIN]["client"] + await aqualink.close() + platforms_to_unload = [ platform for platform in PLATFORMS if platform in hass.data[DOMAIN] ] @@ -226,8 +241,8 @@ class AqualinkEntity(Entity): """Return the device info.""" return DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Jandy", - model=self.dev.__class__.__name__.replace("Aqualink", ""), + manufacturer=self.dev.manufacturer, + model=self.dev.model, name=self.name, via_device=(DOMAIN, self.dev.system.serial), ) diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 725f1b9084ea8faee848b78edcbc85e2aafabed7..408bd56778e1c86b203c540a1472715d315f5e31 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -4,14 +4,6 @@ from __future__ import annotations import logging from typing import Any -from iaqualink.const import ( - AQUALINK_TEMP_CELSIUS_HIGH, - AQUALINK_TEMP_CELSIUS_LOW, - AQUALINK_TEMP_FAHRENHEIT_HIGH, - AQUALINK_TEMP_FAHRENHEIT_LOW, -) -from iaqualink.device import AqualinkHeater, AqualinkPump, AqualinkSensor, AqualinkState - from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, ClimateEntity, @@ -55,17 +47,10 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): """Return the name of the thermostat.""" return self.dev.label.split(" ")[0] - @property - def pump(self) -> AqualinkPump: - """Return the pump device for the current thermostat.""" - pump = f"{self.name.lower()}_pump" - return self.dev.system.devices[pump] - @property def hvac_mode(self) -> HVACMode: """Return the current HVAC mode.""" - state = AqualinkState(self.heater.state) - if state == AqualinkState.ON: + if self.dev.is_on is True: return HVACMode.HEAT return HVACMode.OFF @@ -73,32 +58,28 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Turn the underlying heater switch on or off.""" if hvac_mode == HVACMode.HEAT: - await await_or_reraise(self.heater.turn_on()) + await await_or_reraise(self.dev.turn_on()) elif hvac_mode == HVACMode.OFF: - await await_or_reraise(self.heater.turn_off()) + await await_or_reraise(self.dev.turn_off()) else: _LOGGER.warning("Unknown operation mode: %s", hvac_mode) @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - if self.dev.system.temp_unit == "F": + if self.dev.unit == "F": return TEMP_FAHRENHEIT return TEMP_CELSIUS @property def min_temp(self) -> int: """Return the minimum temperature supported by the thermostat.""" - if self.temperature_unit == TEMP_FAHRENHEIT: - return AQUALINK_TEMP_FAHRENHEIT_LOW - return AQUALINK_TEMP_CELSIUS_LOW + return self.dev.min_temperature @property def max_temp(self) -> int: """Return the minimum temperature supported by the thermostat.""" - if self.temperature_unit == TEMP_FAHRENHEIT: - return AQUALINK_TEMP_FAHRENHEIT_HIGH - return AQUALINK_TEMP_CELSIUS_HIGH + return self.dev.max_temperature @property def target_temperature(self) -> float: @@ -110,21 +91,9 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): """Set new target temperature.""" await await_or_reraise(self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE]))) - @property - def sensor(self) -> AqualinkSensor: - """Return the sensor device for the current thermostat.""" - sensor = f"{self.name.lower()}_temp" - return self.dev.system.devices[sensor] - @property def current_temperature(self) -> float | None: """Return the current temperature.""" - if self.sensor.state != "": - return float(self.sensor.state) + if self.dev.current_temperature != "": + return float(self.dev.current_temperature) return None - - @property - def heater(self) -> AqualinkHeater: - """Return the heater device for the current thermostat.""" - heater = f"{self.name.lower()}_heater" - return self.dev.system.devices[heater] diff --git a/homeassistant/components/iaqualink/const.py b/homeassistant/components/iaqualink/const.py index 189d7083b2d58542457e07166dd5358f95aca960..7cabfa2b4f680d6f6da81149dccc8412112d74d1 100644 --- a/homeassistant/components/iaqualink/const.py +++ b/homeassistant/components/iaqualink/const.py @@ -2,4 +2,4 @@ from datetime import timedelta DOMAIN = "iaqualink" -UPDATE_INTERVAL = timedelta(seconds=30) +UPDATE_INTERVAL = timedelta(seconds=15) diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 3a8b1e0fb4ac9a503a38d861be52bdb948954243..91ca64a87e60beadfe390a9a3e0a2117b21bd383 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -90,7 +90,7 @@ class HassAqualinkLight(AqualinkEntity, LightEntity): @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" - if self.dev.is_dimmer: + if self.dev.supports_brightness: return ColorMode.BRIGHTNESS return ColorMode.ONOFF @@ -102,7 +102,7 @@ class HassAqualinkLight(AqualinkEntity, LightEntity): @property def supported_features(self) -> int: """Return the list of features supported by the light.""" - if self.dev.is_color: + if self.dev.supports_effect: return LightEntityFeature.EFFECT return 0 diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index 7c57744fd3b0a9cee7c4867e6885550ac0948bb4..d5b7d7de0d8bf5cd615b22ce0a12abe672bbfd00 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iaqualink/", "codeowners": ["@flz"], - "requirements": ["iaqualink==0.4.1"], + "requirements": ["iaqualink==0.5.0"], "iot_class": "cloud_polling", "loggers": ["iaqualink"] } diff --git a/homeassistant/components/ibeacon/__init__.py b/homeassistant/components/ibeacon/__init__.py index 2c191211910b19c62f231679dfb19b44e6e2addc..02a19ef6332d8316426ff8691943d6b14f0d3b74 100644 --- a/homeassistant/components/ibeacon/__init__.py +++ b/homeassistant/components/ibeacon/__init__.py @@ -13,7 +13,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Bluetooth LE Tracker from a config entry.""" coordinator = hass.data[DOMAIN] = IBeaconCoordinator(hass, entry, async_get(hass)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - coordinator.async_start() + await coordinator.async_start() return True diff --git a/homeassistant/components/ibeacon/const.py b/homeassistant/components/ibeacon/const.py index 7d1ab15da0aaede25def8959a920911db5c139d0..19b3a6f65991e339bcdfd6224fc3cec553ea9008 100644 --- a/homeassistant/components/ibeacon/const.py +++ b/homeassistant/components/ibeacon/const.py @@ -2,6 +2,9 @@ from datetime import timedelta +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.const import Platform DOMAIN = "ibeacon" @@ -31,5 +34,15 @@ MAX_IDS = 10 # we will add it to the ignore list since its garbage data. MAX_IDS_PER_UUID = 50 +# Number of times a beacon must be seen before it is added to the system +# This is to prevent devices that are just passing by from being added +# to the system. +MIN_SEEN_TRANSIENT_NEW = ( + round( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS / UPDATE_INTERVAL.total_seconds() + ) + + 1 +) + CONF_IGNORE_ADDRESSES = "ignore_addresses" CONF_IGNORE_UUIDS = "ignore_uuids" diff --git a/homeassistant/components/ibeacon/coordinator.py b/homeassistant/components/ibeacon/coordinator.py index 9979cdf4fa8e202b8761f83e1ffeb3727dee2fe5..33b33c56ed0fec9c75f71d5144c7f5b0ea0fc9e5 100644 --- a/homeassistant/components/ibeacon/coordinator.py +++ b/homeassistant/components/ibeacon/coordinator.py @@ -9,8 +9,7 @@ from ibeacon_ble import ( IBEACON_FIRST_BYTE, IBEACON_SECOND_BYTE, iBeaconAdvertisement, - is_ibeacon_service_info, - parse, + iBeaconParser, ) from homeassistant.components import bluetooth @@ -27,6 +26,7 @@ from .const import ( DOMAIN, MAX_IDS, MAX_IDS_PER_UUID, + MIN_SEEN_TRANSIENT_NEW, SIGNAL_IBEACON_DEVICE_NEW, SIGNAL_IBEACON_DEVICE_SEEN, SIGNAL_IBEACON_DEVICE_UNAVAILABLE, @@ -111,6 +111,7 @@ class IBeaconCoordinator: self.hass = hass self._entry = entry self._dev_reg = registry + self._ibeacon_parser = iBeaconParser() # iBeacon devices that do not follow the spec # and broadcast custom data in the major and minor fields @@ -125,6 +126,7 @@ class IBeaconCoordinator: self._last_ibeacon_advertisement_by_unique_id: dict[ str, iBeaconAdvertisement ] = {} + self._transient_seen_count: dict[str, int] = {} self._group_ids_by_address: dict[str, set[str]] = {} self._unique_ids_by_address: dict[str, set[str]] = {} self._unique_ids_by_group_id: dict[str, set[str]] = {} @@ -161,6 +163,7 @@ class IBeaconCoordinator: def _async_cancel_unavailable_tracker(self, address: str) -> None: """Cancel unavailable tracking for an address.""" self._unavailable_trackers.pop(address)() + self._transient_seen_count.pop(address, None) @callback def _async_ignore_uuid(self, uuid: str) -> None: @@ -236,7 +239,7 @@ class IBeaconCoordinator: """Update from a bluetooth callback.""" if service_info.address in self._ignore_addresses: return - if not (ibeacon_advertisement := parse(service_info)): + if not (ibeacon_advertisement := self._ibeacon_parser.parse(service_info)): return uuid_str = str(ibeacon_advertisement.uuid) @@ -297,12 +300,21 @@ class IBeaconCoordinator: or service_info.device.name.replace("-", ":") == service_info.device.address ): return + previously_tracked = address in self._unique_ids_by_address self._last_ibeacon_advertisement_by_unique_id[unique_id] = ibeacon_advertisement self._async_track_ibeacon_with_unique_address(address, group_id, unique_id) if address not in self._unavailable_trackers: self._unavailable_trackers[address] = bluetooth.async_track_unavailable( self.hass, self._async_handle_unavailable, address ) + + if not previously_tracked and new and ibeacon_advertisement.transient: + # Do not create a new tracker right away for transient devices + # If they keep advertising, we will create entities for them + # once _async_update_rssi_and_transients has seen them enough times + self._transient_seen_count[address] = 1 + return + # Some manufacturers violate the spec and flood us with random # data (sometimes its temperature data). # @@ -349,23 +361,42 @@ class IBeaconCoordinator: async_dispatcher_send(self.hass, signal_unavailable(group_id)) @callback - def _async_update_rssi(self) -> None: + def _async_update_rssi_and_transients(self) -> None: """Check to see if the rssi has changed and update any devices. We don't callback on RSSI changes so we need to check them here and send them over the dispatcher periodically to ensure the distance calculation is update. + + If the transient flag is set we also need to check to see + if the device is still transmitting and increment the counter """ for ( unique_id, ibeacon_advertisement, ) in self._last_ibeacon_advertisement_by_unique_id.items(): address = unique_id.split("_")[-1] - if ( - service_info := bluetooth.async_last_service_info( - self.hass, address, connectable=False - ) - ) and service_info.rssi != ibeacon_advertisement.rssi: + service_info = bluetooth.async_last_service_info( + self.hass, address, connectable=False + ) + if not service_info: + continue + + if address in self._transient_seen_count: + self._transient_seen_count[address] += 1 + if self._transient_seen_count[address] == MIN_SEEN_TRANSIENT_NEW: + self._transient_seen_count.pop(address) + _async_dispatch_update( + self.hass, + unique_id, + service_info, + ibeacon_advertisement, + True, + True, + ) + continue + + if service_info.rssi != ibeacon_advertisement.rssi: ibeacon_advertisement.update_rssi(service_info.rssi) async_dispatcher_send( self.hass, @@ -377,7 +408,7 @@ class IBeaconCoordinator: def _async_update(self, _now: datetime) -> None: """Update the Coordinator.""" self._async_check_unavailable_groups_with_random_macs() - self._async_update_rssi() + self._async_update_rssi_and_transients() @callback def _async_restore_from_registry(self) -> None: @@ -403,9 +434,9 @@ class IBeaconCoordinator: group_id = f"{uuid}_{major}_{minor}" self._group_ids_random_macs.add(group_id) - @callback - def async_start(self) -> None: + async def async_start(self) -> None: """Start the Coordinator.""" + await self._ibeacon_parser.async_setup() self._async_restore_from_registry() entry = self._entry entry.async_on_unload( @@ -421,14 +452,6 @@ class IBeaconCoordinator: ) ) entry.async_on_unload(self._async_stop) - # Replay any that are already there. - for service_info in bluetooth.async_discovered_service_info( - self.hass, connectable=False - ): - if is_ibeacon_service_info(service_info): - self._async_update_ibeacon( - service_info, bluetooth.BluetoothChange.ADVERTISEMENT - ) entry.async_on_unload( async_track_time_interval(self.hass, self._async_update, UPDATE_INTERVAL) ) diff --git a/homeassistant/components/ibeacon/manifest.json b/homeassistant/components/ibeacon/manifest.json index a2b55a69403058d7cf65b767068efe711b57b354..ade53491a4cabb0df30de3bdffc93be83c04ceab 100644 --- a/homeassistant/components/ibeacon/manifest.json +++ b/homeassistant/components/ibeacon/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/ibeacon", "dependencies": ["bluetooth"], "bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [2, 21] }], - "requirements": ["ibeacon_ble==0.7.3"], + "requirements": ["ibeacon_ble==1.0.1"], "codeowners": ["@bdraco"], "iot_class": "local_push", "loggers": ["bleak"], diff --git a/homeassistant/components/ibeacon/sensor.py b/homeassistant/components/ibeacon/sensor.py index 4684efdf142d5ba9478c610310d33dff786ba6f2..32c17957b60f74fc472479ecc99caf85badcf816 100644 --- a/homeassistant/components/ibeacon/sensor.py +++ b/homeassistant/components/ibeacon/sensor.py @@ -27,7 +27,7 @@ from .entity import IBeaconEntity class IBeaconRequiredKeysMixin: """Mixin for required keys.""" - value_fn: Callable[[iBeaconAdvertisement], int | None] + value_fn: Callable[[iBeaconAdvertisement], str | int | None] @dataclass @@ -63,6 +63,12 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DISTANCE, ), + IBeaconSensorEntityDescription( + key="vendor", + name="Vendor", + entity_registry_enabled_default=False, + value_fn=lambda ibeacon_advertisement: ibeacon_advertisement.vendor, + ), ) @@ -132,6 +138,6 @@ class IBeaconSensorEntity(IBeaconEntity, SensorEntity): self.async_write_ha_state() @property - def native_value(self) -> int | None: + def native_value(self) -> str | int | None: """Return the state of the sensor.""" return self.entity_description.value_fn(self._ibeacon_advertisement) diff --git a/homeassistant/components/ibeacon/translations/et.json b/homeassistant/components/ibeacon/translations/et.json new file mode 100644 index 0000000000000000000000000000000000000000..34d319b2a088e2fc9b0b8f634fee24ed55affbd0 --- /dev/null +++ b/homeassistant/components/ibeacon/translations/et.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "bluetooth_not_available": "iBeacon Tracker'i kasutamiseks peab olema konfigureeritud v\u00e4hemalt \u00fcks Bluetooth-adapter v\u00f5i kaugjuhtimispult.", + "single_instance_allowed": "Juba h\u00e4\u00e4lestatud. V\u00f5imalik on ainult \u00fcks sidumine." + }, + "step": { + "user": { + "description": "Kas soovite iBeacon Tracker?" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "min_rssi": "Minimaalne RSSI" + }, + "description": "iBeacone, mille RSSI v\u00e4\u00e4rtus on madalam kui minimaalne RSSI, ignoreeritakse. Kui integratsioon n\u00e4eb naabruses asuvaid iBeacone, v\u00f5ib selle v\u00e4\u00e4rtuse suurendamine aidata." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ibeacon/translations/ja.json b/homeassistant/components/ibeacon/translations/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..e399d7b002624d1482e9dcf1cad018ed1ae3ed7c --- /dev/null +++ b/homeassistant/components/ibeacon/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u8a2d\u5b9a\u3067\u304d\u308b\u306e\u306f1\u3064\u3060\u3051\u3067\u3059\u3002" + }, + "step": { + "user": { + "description": "iBeacon Tracker\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "min_rssi": "\u6700\u5c0fRSSI" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ibeacon/translations/nb.json b/homeassistant/components/ibeacon/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..353183280b640384aade972bcf719fd3c613defd --- /dev/null +++ b/homeassistant/components/ibeacon/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Allerede konfigurert. Kun \u00e9n enkelt konfigurasjon er mulig." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ibeacon/translations/nl.json b/homeassistant/components/ibeacon/translations/nl.json index 703ac8614c49d481018a2d89252f8afcd9c70a43..9102d3c69aaba4c662047c8b178889fa6161f266 100644 --- a/homeassistant/components/ibeacon/translations/nl.json +++ b/homeassistant/components/ibeacon/translations/nl.json @@ -2,6 +2,20 @@ "config": { "abort": { "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." + }, + "step": { + "user": { + "description": "Wilt u iBeacon Tracker instellen?" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "min_rssi": "Minimale RSSI" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/ibeacon/translations/pt-BR.json b/homeassistant/components/ibeacon/translations/pt-BR.json index 0dfe8a4d8cd98398c5cc7039c697be5054763eef..7166796774618504e704a1df271f55c20ba65dcf 100644 --- a/homeassistant/components/ibeacon/translations/pt-BR.json +++ b/homeassistant/components/ibeacon/translations/pt-BR.json @@ -2,7 +2,7 @@ "config": { "abort": { "bluetooth_not_available": "Pelo menos um adaptador ou controle remoto Bluetooth deve ser configurado para usar o iBeacon Tracker.", - "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." }, "step": { "user": { diff --git a/homeassistant/components/ibeacon/translations/sv.json b/homeassistant/components/ibeacon/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..14ce1f415cc1d866d7adc4799f6f80273965018e --- /dev/null +++ b/homeassistant/components/ibeacon/translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "bluetooth_not_available": "Minst en Bluetooth-adapter eller proxy m\u00e5ste konfigureras f\u00f6r att anv\u00e4nda iBeacon Tracker.", + "single_instance_allowed": "Redan konfigurerad. Endast en konfiguration m\u00f6jlig." + }, + "step": { + "user": { + "description": "Vill du konfigurera iBeacon Tracker?" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "min_rssi": "Minsta RSSI" + }, + "description": "iBeacons med ett RSSI-v\u00e4rde som \u00e4r l\u00e4gre \u00e4n det l\u00e4gsta RSSI-v\u00e4rdet ignoreras. Om integrationen ser n\u00e4rliggande iBeacons kan det hj\u00e4lpa att \u00f6ka detta v\u00e4rde." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ibeacon/translations/tr.json b/homeassistant/components/ibeacon/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..2bb5e530f6dce3193fb85b45cd81ff2bf11bac88 --- /dev/null +++ b/homeassistant/components/ibeacon/translations/tr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "bluetooth_not_available": "iBeacon Tracker'\u0131 kullanmak i\u00e7in en az bir Bluetooth adapt\u00f6r\u00fc veya uzaktan kumanda yap\u0131land\u0131r\u0131lmal\u0131d\u0131r.", + "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." + }, + "step": { + "user": { + "description": "iBeacon Tracker'\u0131 kurmak istiyor musunuz?" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "min_rssi": "Minimum RSSI" + }, + "description": "Minimum RSSI de\u011ferinden daha d\u00fc\u015f\u00fck bir RSSI de\u011ferine sahip iBeacon'lar yok say\u0131l\u0131r. Entegrasyon kom\u015fu iBeacon'lar\u0131 g\u00f6r\u00fcyorsa, bu de\u011feri art\u0131rmak yard\u0131mc\u0131 olabilir." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index c51f6a3ac2605cdc478b83490a47881aa1c69219..7dc0e0ec830973aa7bb75f4598f393aa2f410559 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -16,7 +16,7 @@ from pyicloud.services.findmyiphone import AppleDevice from homeassistant.components.zone import async_active_zone from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_USERNAME +from homeassistant.const import CONF_USERNAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import dispatcher_send @@ -48,8 +48,6 @@ from .const import ( DOMAIN, ) -ATTRIBUTION = "Data provided by Apple iCloud" - # entity attributes ATTR_ACCOUNT_FETCH_INTERVAL = "account_fetch_interval" ATTR_BATTERY = "battery" @@ -368,6 +366,8 @@ class IcloudAccount: class IcloudDevice: """Representation of a iCloud device.""" + _attr_attribution = "Data provided by Apple iCloud" + def __init__(self, account: IcloudAccount, device: AppleDevice, status) -> None: """Initialize the iCloud device.""" self._account = account @@ -385,7 +385,6 @@ class IcloudDevice: self._location = None self._attrs = { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_ACCOUNT_FETCH_INTERVAL: self._account.fetch_interval, ATTR_DEVICE_NAME: self._device_model, ATTR_DEVICE_STATUS: None, diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 44297a21112824db5afe588eb6b47189beb3db83..fc1de213a69302c99fb616f2fb59f69e359a75d6 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -51,8 +51,7 @@ def add_entities(account: IcloudAccount, async_add_entities, tracked): new_tracked.append(IcloudTrackerEntity(account, device)) tracked.add(dev_id) - if new_tracked: - async_add_entities(new_tracked, True) + async_add_entities(new_tracked, True) class IcloudTrackerEntity(TrackerEntity): diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index c3b23057ebc4971c8d8f50f125597a0ea9f6d6c4..e7c982607cbf85b27308f2ec71534b48077ec465 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -47,8 +47,7 @@ def add_entities(account, async_add_entities, tracked): new_tracked.append(IcloudDeviceBatterySensor(account, device)) tracked.add(dev_id) - if new_tracked: - async_add_entities(new_tracked, True) + async_add_entities(new_tracked, True) class IcloudDeviceBatterySensor(SensorEntity): diff --git a/homeassistant/components/icloud/translations/bg.json b/homeassistant/components/icloud/translations/bg.json index 3c54ef831d1e6ed62aa9dac366ee08d09fe72586..fb3d0a4ce4836447b68bbcb63c88f69c0213dc34 100644 --- a/homeassistant/components/icloud/translations/bg.json +++ b/homeassistant/components/icloud/translations/bg.json @@ -24,7 +24,7 @@ "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "Email" + "username": "\u0418\u043c\u0435\u0439\u043b" } }, "verification_code": { diff --git a/homeassistant/components/icloud/translations/he.json b/homeassistant/components/icloud/translations/he.json index 73f09385a36acb94a5a9b2658e87620d855bfc01..eae7fa97a834fd1434b2c92ccac64a7b922d072e 100644 --- a/homeassistant/components/icloud/translations/he.json +++ b/homeassistant/components/icloud/translations/he.json @@ -15,6 +15,12 @@ "description": "\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d4\u05d6\u05e0\u05ea \u05d1\u05e2\u05d1\u05e8 \u05e2\u05d1\u05d5\u05e8 {username} \u05d0\u05d9\u05e0\u05d4 \u05e4\u05d5\u05e2\u05dc\u05ea \u05e2\u05d5\u05d3. \u05e2\u05d3\u05db\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05d4\u05de\u05e9\u05d9\u05da \u05dc\u05d4\u05e9\u05ea\u05de\u05e9 \u05d1\u05e9\u05d9\u05dc\u05d5\u05d1 \u05d6\u05d4.", "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" }, + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, "trusted_device": { "data": { "trusted_device": "\u05de\u05db\u05e9\u05d9\u05e8 \u05de\u05d4\u05d9\u05de\u05df" diff --git a/homeassistant/components/icloud/translations/no.json b/homeassistant/components/icloud/translations/no.json index 662600eac36d407d76cb5f652bd57946b6acc55d..1423f117126c59948c822c29151e333ecd1f2fa7 100644 --- a/homeassistant/components/icloud/translations/no.json +++ b/homeassistant/components/icloud/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Kontoen er allerede konfigurert", "no_device": "Ingen av enhetene dine har \"Finn min iPhone\" aktivert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index e8076576f6ef8a4bc8235ed4944e34d05196ee85..97234de9d6da88dcddbf5e9f161a4ed4590ace8b 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -13,8 +13,5 @@ "requirements": ["inkbird-ble==0.5.5"], "dependencies": ["bluetooth"], "codeowners": ["@bdraco"], - "iot_class": "local_push", - "supported_brands": { - "nutrichef": "Nutrichef" - } + "iot_class": "local_push" } diff --git a/homeassistant/components/inkbird/translations/hu.json b/homeassistant/components/inkbird/translations/hu.json index 7ef0d3a63013dc9a7c1814fe3c80d99ab7dede60..e1673194c6d885ee4f8bae1faa811bd0f14444f2 100644 --- a/homeassistant/components/inkbird/translations/hu.json +++ b/homeassistant/components/inkbird/translations/hu.json @@ -14,7 +14,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/inspired_shades/manifest.json b/homeassistant/components/inspired_shades/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..5c8f3bdc10b7ecd8a74b1a39d84a3202528e026d --- /dev/null +++ b/homeassistant/components/inspired_shades/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "inspired_shades", + "name": "Inspired Shades", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/insteon/api/aldb.py b/homeassistant/components/insteon/api/aldb.py index a119707b03d83376a88fe99e7f92cb23b4f83054..77ece2709d8b073814af67e9ea882b84ab9efe1e 100644 --- a/homeassistant/components/insteon/api/aldb.py +++ b/homeassistant/components/insteon/api/aldb.py @@ -1,5 +1,7 @@ """Web socket API for Insteon devices.""" +from typing import Any + from pyinsteon import devices from pyinsteon.constants import ALDBStatus from pyinsteon.topics import ( @@ -71,7 +73,7 @@ async def async_reload_and_save_aldb(hass, device): async def websocket_get_aldb( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Get the All-Link Database for an Insteon device.""" if not (device := devices[msg[DEVICE_ADDRESS]]): @@ -107,7 +109,7 @@ async def websocket_get_aldb( async def websocket_change_aldb_record( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Change an All-Link Database record for an Insteon device.""" if not (device := devices[msg[DEVICE_ADDRESS]]): @@ -140,7 +142,7 @@ async def websocket_change_aldb_record( async def websocket_create_aldb_record( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Create an All-Link Database record for an Insteon device.""" if not (device := devices[msg[DEVICE_ADDRESS]]): @@ -170,7 +172,7 @@ async def websocket_create_aldb_record( async def websocket_write_aldb( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Create an All-Link Database record for an Insteon device.""" if not (device := devices[msg[DEVICE_ADDRESS]]): @@ -193,7 +195,7 @@ async def websocket_write_aldb( async def websocket_load_aldb( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Create an All-Link Database record for an Insteon device.""" if not (device := devices[msg[DEVICE_ADDRESS]]): @@ -215,7 +217,7 @@ async def websocket_load_aldb( async def websocket_reset_aldb( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Create an All-Link Database record for an Insteon device.""" if not (device := devices[msg[DEVICE_ADDRESS]]): @@ -237,7 +239,7 @@ async def websocket_reset_aldb( async def websocket_add_default_links( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Add the default All-Link Database records for an Insteon device.""" if not (device := devices[msg[DEVICE_ADDRESS]]): @@ -261,7 +263,7 @@ async def websocket_add_default_links( async def websocket_notify_on_aldb_status( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Tell Insteon a new ALDB record was added.""" if not (device := devices[msg[DEVICE_ADDRESS]]): diff --git a/homeassistant/components/insteon/api/device.py b/homeassistant/components/insteon/api/device.py index ea6bade4835db630356250f6e2484f6c2c5ee2eb..bffda96545686badd7776d196b3d55b1030416b2 100644 --- a/homeassistant/components/insteon/api/device.py +++ b/homeassistant/components/insteon/api/device.py @@ -1,5 +1,7 @@ """API interface to get an Insteon device.""" +from typing import Any + from pyinsteon import devices from pyinsteon.constants import DeviceAction import voluptuous as vol @@ -66,7 +68,7 @@ def notify_device_not_found(connection, msg, text): async def websocket_get_device( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Get an Insteon device.""" dev_registry = dr.async_get(hass) @@ -98,7 +100,7 @@ async def websocket_get_device( async def websocket_add_device( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Add one or more Insteon devices.""" @@ -134,7 +136,7 @@ async def websocket_add_device( async def websocket_cancel_add_device( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Cancel the Insteon all-linking process.""" await devices.async_cancel_all_linking() diff --git a/homeassistant/components/insteon/api/properties.py b/homeassistant/components/insteon/api/properties.py index 8e697a8845958aec1f2924a8ff81afca74d7df10..7350ab1474347ddd878d55f8b2ad8441c2d04d6d 100644 --- a/homeassistant/components/insteon/api/properties.py +++ b/homeassistant/components/insteon/api/properties.py @@ -1,5 +1,7 @@ """Property update methods and schemas.""" +from typing import Any + from pyinsteon import devices from pyinsteon.config import RADIO_BUTTON_GROUPS, RAMP_RATE_IN_SEC, get_usable_value from pyinsteon.constants import ( @@ -158,7 +160,7 @@ def update_property(device, prop_name, value): async def websocket_get_properties( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Add the default All-Link Database records for an Insteon device.""" if not (device := devices[msg[DEVICE_ADDRESS]]): @@ -183,7 +185,7 @@ async def websocket_get_properties( async def websocket_change_properties_record( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Add the default All-Link Database records for an Insteon device.""" if not (device := devices[msg[DEVICE_ADDRESS]]): @@ -205,7 +207,7 @@ async def websocket_change_properties_record( async def websocket_write_properties( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Add the default All-Link Database records for an Insteon device.""" if not (device := devices[msg[DEVICE_ADDRESS]]): @@ -235,7 +237,7 @@ async def websocket_write_properties( async def websocket_load_properties( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Add the default All-Link Database records for an Insteon device.""" if not (device := devices[msg[DEVICE_ADDRESS]]): @@ -266,7 +268,7 @@ async def websocket_load_properties( async def websocket_reset_properties( hass: HomeAssistant, connection: websocket_api.connection.ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Add the default All-Link Database records for an Insteon device.""" if not (device := devices[msg[DEVICE_ADDRESS]]): diff --git a/homeassistant/components/insteon/translations/de.json b/homeassistant/components/insteon/translations/de.json index d7f853d9a2a66b49f4f3c2023eb3e7a75517fa28..ffbad247632aeda1e0f437f3883c9aa1074b6778 100644 --- a/homeassistant/components/insteon/translations/de.json +++ b/homeassistant/components/insteon/translations/de.json @@ -19,7 +19,7 @@ "host": "IP-Adresse", "port": "Port" }, - "description": "Konfiguriere den Insteon Hub Version 1 (vor 2014).", + "description": "Konfiguriere den Insteon Hub Version 1 (pr\u00e4-2014).", "title": "Insteon Hub Version 1" }, "hubv2": { diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 09375e7827a453711ecade3cfaa0d49bb13fe403..c5dbba9c25b312766482021eaf5c7a803cce89e4 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -392,5 +392,4 @@ def async_add_insteon_entities( groups = get_platform_groups(device, platform) for group in groups: new_entities.append(entity_type(device, group)) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) diff --git a/homeassistant/components/integration/translations/sv.json b/homeassistant/components/integration/translations/sv.json index edba53d631069f58c2738f383853639ea38f2d16..f47ae6164d2d25d0ddd43ffc33d69b0b81de18d5 100644 --- a/homeassistant/components/integration/translations/sv.json +++ b/homeassistant/components/integration/translations/sv.json @@ -15,8 +15,8 @@ "unit_prefix": "Utdata kommer att skalas enligt det valda metriska prefixet.", "unit_time": "Utg\u00e5ngen kommer att skalas enligt den valda tidsenheten." }, - "description": "Skapa en sensor som ber\u00e4knar en Riemanns summa f\u00f6r att uppskatta integralen av en sensor.", - "title": "L\u00e4gg till Riemann summa integral sensor" + "description": "Skapa en sensor som ber\u00e4knar en Riemannsumma f\u00f6r att uppskatta integralen av en sensor.", + "title": "L\u00e4gg till Riemannsumma integralsensor" } } }, @@ -32,5 +32,5 @@ } } }, - "title": "Integration - Riemann summa integral sensor" + "title": "Integral - Riemannsumma integralsensor" } \ No newline at end of file diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index 020136f078cae207658638b106795a755a5d497d..e4e4f1a66c9d3bcf90a11a1c648e112ccb8d5dcf 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -24,6 +24,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.FAN, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index d37bef2189af65bf00cd87193100d3b74b3fed4b..0f43856938955944adc6fb6e9bfaa6db403ea885 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -125,6 +125,5 @@ class IntellifireFan(IntellifireEntity, FanEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" - self.coordinator.control_api.fan_off() await self.entity_description.set_fn(self.coordinator.control_api, 0) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py new file mode 100644 index 0000000000000000000000000000000000000000..efa567d55cbdb0a95fc74ffb9c7f40cd19a31bdd --- /dev/null +++ b/homeassistant/components/intellifire/number.py @@ -0,0 +1,77 @@ +"""Flame height number sensors.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, LOGGER +from .coordinator import IntellifireDataUpdateCoordinator +from .entity import IntellifireEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the fans.""" + coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + description = NumberEntityDescription( + key="flame_control", + name="Flame control", + icon="mdi:arrow-expand-vertical", + ) + + async_add_entities( + [ + IntellifireFlameControlEntity( + coordinator=coordinator, description=description + ) + ] + ) + + +@dataclass +class IntellifireFlameControlEntity(IntellifireEntity, NumberEntity): + """Flame height control entity.""" + + _attr_native_max_value: float = 5 + _attr_native_min_value: float = 1 + _attr_native_step: float = 1 + _attr_mode: NumberMode = NumberMode.SLIDER + + def __init__( + self, + coordinator: IntellifireDataUpdateCoordinator, + description: NumberEntityDescription, + ) -> None: + """Initilaize Flame height Sensor.""" + super().__init__(coordinator, description) + + @property + def native_value(self) -> float | None: + """Return the current Flame Height segment number value.""" + # UI uses 1-5 for flame height, backing lib uses 0-4 + value = self.coordinator.read_api.data.flameheight + 1 + return value + + async def async_set_native_value(self, value: float) -> None: + """Slider change.""" + value_to_send: int = int(value) - 1 + LOGGER.debug( + "%s set flame height to %d with raw value %s", + self._attr_name, + value, + value_to_send, + ) + await self.coordinator.control_api.set_flame_height(height=value_to_send) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index 3bb3614f71ea6dcd93d6b5d0998a6d592f753fae..cbd31249133dcc1734f89eb993dcd496365b004d 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -60,7 +60,8 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( icon="mdi:fire-circle", name="Flame Height", state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.flameheight, + # UI uses 1-5 for flame height, backing lib uses 0-4 + value_fn=lambda data: (data.flameheight + 1), ), IntellifireSensorEntityDescription( key="temperature", diff --git a/homeassistant/components/intellifire/translations/bg.json b/homeassistant/components/intellifire/translations/bg.json index 9df377170f4db9994934cccf0dde1eab586f836a..0a0b8c64e8e5566f2a441b5e3b296c61838a2678 100644 --- a/homeassistant/components/intellifire/translations/bg.json +++ b/homeassistant/components/intellifire/translations/bg.json @@ -14,7 +14,7 @@ "api_config": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "Email" + "username": "\u0418\u043c\u0435\u0439\u043b" } }, "dhcp_confirm": { diff --git a/homeassistant/components/intellifire/translations/no.json b/homeassistant/components/intellifire/translations/no.json index 53cf7495b946d8a58b11899bb299a7099612a989..77fdb74a3ab88d1c917266107b8e5ab83c67a8f0 100644 --- a/homeassistant/components/intellifire/translations/no.json +++ b/homeassistant/components/intellifire/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Enheten er allerede konfigurert", "not_intellifire_device": "Ikke en IntelliFire-enhet.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "api_error": "Innlogging feilet", diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 8bcf5bfae9aca9134201974feb788171f1cdf9b0..dd04a32cce4729556fc55b6fcf0627e33458777a 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -154,8 +154,7 @@ async def async_setup_entry( for key in coordinator.data["sensors"] if key not in created ] - if entities: - async_add_entities(entities) + async_add_entities(entities) coordinator.async_add_listener(new_data_received) diff --git a/homeassistant/components/iotawatt/translations/nb.json b/homeassistant/components/iotawatt/translations/nb.json index b97053efa85815dd1108b8e4f94e342312535dcd..4630e6af74734103a9185aa82b4a3516974deb7d 100644 --- a/homeassistant/components/iotawatt/translations/nb.json +++ b/homeassistant/components/iotawatt/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "auth": { "data": { diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index 2dffabeeb8c2c0818d7b0d86f6b8bbbe6644a935..3d13c302606cc8cbf6f35329129bc5311c931c35 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -11,8 +11,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ATTR_VERSION, DATA_UPDATED, DOMAIN as IPERF3_DOMAIN, SENSOR_TYPES -ATTRIBUTION = "Data retrieved using Iperf3" - ICON = "mdi:speedometer" ATTR_PROTOCOL = "Protocol" @@ -42,6 +40,7 @@ async def async_setup_platform( class Iperf3Sensor(RestoreEntity, SensorEntity): """A Iperf3 sensor implementation.""" + _attr_attribution = "Data retrieved using Iperf3" _attr_icon = ICON _attr_should_poll = False @@ -55,7 +54,6 @@ class Iperf3Sensor(RestoreEntity, SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" return { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_PROTOCOL: self._iperf3_data.protocol, ATTR_REMOTE_HOST: self._iperf3_data.host, ATTR_REMOTE_PORT: self._iperf3_data.port, diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py index eec16a0c8116bfdd6ac5e110c29d11da08d9d5f4..866e79cbe404e2cd50d11e12847fcc8d452e3a71 100644 --- a/homeassistant/components/ipma/__init__.py +++ b/homeassistant/components/ipma/__init__.py @@ -57,4 +57,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - 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) + + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return unload_ok diff --git a/homeassistant/components/ipma/const.py b/homeassistant/components/ipma/const.py index 60c8115a5c4656261bf9c3cef34ab1a265039525..515fb501fbd03ce2f447e7a2a02e6724363d7a58 100644 --- a/homeassistant/components/ipma/const.py +++ b/homeassistant/components/ipma/const.py @@ -5,7 +5,7 @@ DOMAIN = "ipma" HOME_LOCATION_NAME = "Home" -ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.ipma_{HOME_LOCATION_NAME}" - -DATA_LOCATION = "location" DATA_API = "api" +DATA_LOCATION = "location" + +ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.ipma_{HOME_LOCATION_NAME}" diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index a0fe5b235b33f0d75e9a10e125791d1b38ae52cd..c448fad592dd56b78ead6e8806976418112f10ce 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -8,7 +8,6 @@ import async_timeout from pyipma.api import IPMA_API from pyipma.forecast import Forecast from pyipma.location import Location -import voluptuous as vol from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -33,13 +32,10 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - PLATFORM_SCHEMA, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_LATITUDE, - CONF_LONGITUDE, CONF_MODE, CONF_NAME, PRESSURE_HPA, @@ -47,7 +43,7 @@ from homeassistant.const import ( TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import entity_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import Throttle @@ -80,15 +76,6 @@ CONDITION_CLASSES = { FORECAST_MODE = ["hourly", "daily"] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_MODE, default="daily"): vol.In(FORECAST_MODE), - } -) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/ipp/entity.py b/homeassistant/components/ipp/entity.py index b2f3a4a1469f64ca3c939daa452dc1aefbe59b1e..50f81f74bdb529fc6234449ef7dcbb410d381eeb 100644 --- a/homeassistant/components/ipp/entity.py +++ b/homeassistant/components/ipp/entity.py @@ -41,4 +41,5 @@ class IPPEntity(CoordinatorEntity[IPPDataUpdateCoordinator]): model=self.coordinator.data.info.model, name=self.coordinator.data.info.name, sw_version=self.coordinator.data.info.version, + configuration_url=self.coordinator.data.info.more_info, ) diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index 39e798f99bff7db4451a8844503345a86ba85eee..b673a2d5a6dccffb5fd61b8b960c103b012ad222 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -2,7 +2,8 @@ "domain": "ipp", "name": "Internet Printing Protocol (IPP)", "documentation": "https://www.home-assistant.io/integrations/ipp", - "requirements": ["pyipp==0.11.0"], + "integration_type": "device", + "requirements": ["pyipp==0.12.1"], "codeowners": ["@ctalkington"], "config_flow": true, "quality_scale": "platinum", diff --git a/homeassistant/components/iqvia/diagnostics.py b/homeassistant/components/iqvia/diagnostics.py index ee722005874cab0c4a896269d4521b286c5c4caa..664467b07021eca027d63640d6a453d46b410a80 100644 --- a/homeassistant/components/iqvia/diagnostics.py +++ b/homeassistant/components/iqvia/diagnostics.py @@ -3,11 +3,32 @@ from __future__ import annotations from typing import Any +from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import CONF_ZIP_CODE, DOMAIN + +CONF_CITY = "City" +CONF_DISPLAY_LOCATION = "DisplayLocation" +CONF_MARKET = "Market" +CONF_TITLE = "title" +CONF_ZIP_CAP = "ZIP" +CONF_STATE_CAP = "State" + +TO_REDACT = { + CONF_CITY, + CONF_DISPLAY_LOCATION, + CONF_MARKET, + CONF_STATE_CAP, + # Config entry title and unique ID may contain sensitive data: + CONF_TITLE, + CONF_UNIQUE_ID, + CONF_ZIP_CAP, + CONF_ZIP_CODE, +} async def async_get_config_entry_diagnostics( @@ -17,12 +38,12 @@ async def async_get_config_entry_diagnostics( coordinators: dict[str, DataUpdateCoordinator] = hass.data[DOMAIN][entry.entry_id] return { - "entry": { - "title": entry.title, - "data": dict(entry.data), - }, - "data": { - data_type: coordinator.data - for data_type, coordinator in coordinators.items() - }, + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "data": async_redact_data( + { + data_type: coordinator.data + for data_type, coordinator in coordinators.items() + }, + TO_REDACT, + ), } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 9da0af32da30a7db7839ec1595254a9319edc469..2bf8eee846906f6c7ef89f52c6dfb34612733369 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -6,5 +6,6 @@ "requirements": ["numpy==1.23.2", "pyiqvia==2022.04.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", - "loggers": ["pyiqvia"] + "loggers": ["pyiqvia"], + "integration_type": "service" } diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 2035080b96d1d64422ac404d70dbb203db262362..d11a0b318098cd5fe458363b4696b8d2b30a1bb2 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -7,7 +7,7 @@ from pyirishrail.pyirishrail import IrishRailRTPI import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES +from homeassistant.const import CONF_NAME, TIME_MINUTES from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,7 +23,6 @@ ATTR_DUE_AT = "Due at" ATTR_EXPECT_AT = "Expected at" ATTR_NEXT_UP = "Later Train" ATTR_TRAIN_TYPE = "Train type" -ATTRIBUTION = "Data provided by Irish Rail" CONF_STATION = "station" CONF_DESTINATION = "destination" @@ -76,6 +75,8 @@ def setup_platform( class IrishRailTransportSensor(SensorEntity): """Implementation of an irish rail public transport sensor.""" + _attr_attribution = "Data provided by Irish Rail" + def __init__(self, data, station, direction, destination, stops_at, name): """Initialize the sensor.""" self.data = data @@ -110,7 +111,6 @@ class IrishRailTransportSensor(SensorEntity): ) return { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_STATION: self._station, ATTR_ORIGIN: self._times[0][ATTR_ORIGIN], ATTR_DESTINATION: self._times[0][ATTR_DESTINATION], diff --git a/homeassistant/components/islamic_prayer_times/const.py b/homeassistant/components/islamic_prayer_times/const.py index 86f953cc8566d4bc84148cc8ca8658fa92d608d4..e037f486aaa3e5b12072d6d30bd094319a8384e4 100644 --- a/homeassistant/components/islamic_prayer_times/const.py +++ b/homeassistant/components/islamic_prayer_times/const.py @@ -1,4 +1,6 @@ """Constants for the Islamic Prayer component.""" +from prayer_times_calculator import PrayerTimesCalculator + DOMAIN = "islamic_prayer_times" NAME = "Islamic Prayer Times" PRAYER_TIMES_ICON = "mdi:calendar-clock" @@ -15,7 +17,7 @@ SENSOR_TYPES = { CONF_CALC_METHOD = "calculation_method" -CALC_METHODS = ["isna", "karachi", "mwl", "makkah", "moonsighting"] +CALC_METHODS: list[str] = list(PrayerTimesCalculator.CALCULATION_METHODS) DEFAULT_CALC_METHOD = "isna" DATA_UPDATED = "Islamic_prayer_data_updated" diff --git a/homeassistant/components/ismartwindow/manifest.json b/homeassistant/components/ismartwindow/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..1e2ca8750074b82577d546a4f342c7fdcff4751a --- /dev/null +++ b/homeassistant/components/ismartwindow/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "ismartwindow", + "name": "iSmartWindow", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/iss/manifest.json b/homeassistant/components/iss/manifest.json index bd1aa967f070fbe8eda939d5215e3eb77b00e40d..d91c35e8f6bb40f65c66986f6be58fabe36e7a49 100644 --- a/homeassistant/components/iss/manifest.json +++ b/homeassistant/components/iss/manifest.json @@ -2,6 +2,7 @@ "domain": "iss", "config_flow": true, "name": "International Space Station (ISS)", + "integration_type": "service", "documentation": "https://www.home-assistant.io/integrations/iss", "requirements": ["pyiss==1.0.1"], "codeowners": ["@DurgNomis-drol"], diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 21cc23b01caf349125cf47f5f8d4f12e3a08bdcb..d9c78f904e2c7290205ba94d8dab445cac0aef4c 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -37,20 +37,17 @@ from homeassistant.const import ( PERCENTAGE, POWER_KILO_WATT, POWER_WATT, - PRECIPITATION_MILLIMETERS_PER_HOUR, PRESSURE_HPA, PRESSURE_INHG, PRESSURE_MBAR, + REVOLUTIONS_PER_MINUTE, SERVICE_LOCK, SERVICE_UNLOCK, SOUND_PRESSURE_DB, SOUND_PRESSURE_WEIGHTED_DBA, - SPEED_INCHES_PER_DAY, - SPEED_INCHES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, - SPEED_MILLIMETERS_PER_DAY, STATE_CLOSED, STATE_CLOSING, STATE_LOCKED, @@ -79,6 +76,7 @@ from homeassistant.const import ( VOLUME_GALLONS, VOLUME_LITERS, Platform, + UnitOfVolumetricFlux, ) _LOGGER = logging.getLogger(__package__) @@ -341,7 +339,7 @@ UOM_FRIENDLY_NAME = { "21": "%AH", "22": "%RH", "23": PRESSURE_INHG, - "24": SPEED_INCHES_PER_HOUR, + "24": UnitOfVolumetricFlux.INCHES_PER_HOUR, UOM_INDEX: UOM_INDEX, # Index type. Use "node.formatted" for value "26": TEMP_KELVIN, "27": "keyword", @@ -363,7 +361,7 @@ UOM_FRIENDLY_NAME = { "43": ELECTRIC_POTENTIAL_MILLIVOLT, "44": TIME_MINUTES, "45": TIME_MINUTES, - "46": PRECIPITATION_MILLIMETERS_PER_HOUR, + "46": UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, "47": TIME_MONTHS, "48": SPEED_MILES_PER_HOUR, "49": SPEED_METERS_PER_SECOND, @@ -396,7 +394,7 @@ UOM_FRIENDLY_NAME = { "86": "kΩ", "87": f"{VOLUME_CUBIC_METERS}/{VOLUME_CUBIC_METERS}", "88": "Water activity", - "89": "RPM", + "89": REVOLUTIONS_PER_MINUTE, "90": FREQUENCY_HERTZ, "91": DEGREE, "92": f"{DEGREE} South", @@ -406,7 +404,7 @@ UOM_FRIENDLY_NAME = { "103": CURRENCY_DOLLAR, "104": CURRENCY_CENT, "105": LENGTH_INCHES, - "106": SPEED_MILLIMETERS_PER_DAY, + "106": UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, "107": "", # raw 1-byte unsigned value "108": "", # raw 2-byte unsigned value "109": "", # raw 3-byte unsigned value @@ -419,7 +417,7 @@ UOM_FRIENDLY_NAME = { "117": PRESSURE_MBAR, "118": PRESSURE_HPA, "119": ENERGY_WATT_HOUR, - "120": SPEED_INCHES_PER_DAY, + "120": UnitOfVolumetricFlux.INCHES_PER_DAY, } UOM_TO_STATES = { diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index f3620ec96631b524617172236902e08d74d6a30b..d1c18fce595177a1cb4e3abc3ebc9ccfd1d93f9f 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -1,8 +1,9 @@ { "domain": "isy994", "name": "Universal Devices ISY994", + "integration_type": "hub", "documentation": "https://www.home-assistant.io/integrations/isy994", - "requirements": ["pyisy==3.0.7"], + "requirements": ["pyisy==3.0.8"], "codeowners": ["@bdraco", "@shbatm"], "config_flow": true, "ssdp": [ diff --git a/homeassistant/components/isy994/translations/nb.json b/homeassistant/components/isy994/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/isy994/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/isy994/translations/no.json b/homeassistant/components/isy994/translations/no.json index e18666d7fc4efaa9ea60971afb54fa61dff73644..813053aa83ad938b0c70d18de8b74cc8a6b80b3c 100644 --- a/homeassistant/components/isy994/translations/no.json +++ b/homeassistant/components/isy994/translations/no.json @@ -7,7 +7,7 @@ "cannot_connect": "Tilkobling mislyktes", "invalid_auth": "Ugyldig godkjenning", "invalid_host": "Vertsoppf\u00f8ringen var ikke i fullstendig URL-format, for eksempel http://192.168.10.100:80", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "unknown": "Uventet feil" }, "flow_title": "{name} ( {host} )", diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index a58108b05abe8e40fd1f10d1f1a12c0075231a63..39085317a54e28cc5d62aea3ca6514d7aa8c2f09 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -1,30 +1,54 @@ """The Jellyfin integration.""" -import logging +from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input -from .const import DATA_CLIENT, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, LOGGER, PLATFORMS +from .coordinator import JellyfinDataUpdateCoordinator, SessionsDataUpdateCoordinator +from .models import JellyfinData async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Jellyfin from a config entry.""" hass.data.setdefault(DOMAIN, {}) - client = create_client() + if CONF_CLIENT_DEVICE_ID not in entry.data: + entry_data = entry.data.copy() + entry_data[CONF_CLIENT_DEVICE_ID] = entry.entry_id + hass.config_entries.async_update_entry(entry, data=entry_data) + + client = create_client( + device_id=entry.data[CONF_CLIENT_DEVICE_ID], + device_name=hass.config.location_name, + ) + try: - await validate_input(hass, dict(entry.data), client) + user_id, connect_result = await validate_input(hass, dict(entry.data), client) except CannotConnect as ex: raise ConfigEntryNotReady("Cannot connect to Jellyfin server") from ex except InvalidAuth: - _LOGGER.error("Failed to login to Jellyfin server") + LOGGER.error("Failed to login to Jellyfin server") return False - else: - hass.data[DOMAIN][entry.entry_id] = {DATA_CLIENT: client} + + server_info: dict[str, Any] = connect_result["Servers"][0] + + coordinators: dict[str, JellyfinDataUpdateCoordinator[Any]] = { + "sessions": SessionsDataUpdateCoordinator(hass, client, server_info, user_id), + } + + for coordinator in coordinators.values(): + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = JellyfinData( + client_device_id=entry.data[CONF_CLIENT_DEVICE_ID], + jellyfin_client=client, + coordinators=coordinators, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py new file mode 100644 index 0000000000000000000000000000000000000000..0e63cb2f5d20d59c1b18167d3cb62d4a25284b3c --- /dev/null +++ b/homeassistant/components/jellyfin/browse_media.py @@ -0,0 +1,179 @@ +"""Support for media browsing.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from jellyfin_apiclient_python import JellyfinClient + +from homeassistant.components.media_player import BrowseError, MediaClass, MediaType +from homeassistant.components.media_player.browse_media import BrowseMedia +from homeassistant.core import HomeAssistant + +from .client_wrapper import get_artwork_url +from .const import CONTENT_TYPE_MAP, MEDIA_CLASS_MAP, MEDIA_TYPE_NONE + +CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS: dict[str, str] = { + MediaType.MUSIC: MediaClass.MUSIC, + MediaType.SEASON: MediaClass.SEASON, + MediaType.TVSHOW: MediaClass.TV_SHOW, + "boxset": MediaClass.DIRECTORY, + "collection": MediaClass.DIRECTORY, + "library": MediaClass.DIRECTORY, +} + +JF_SUPPORTED_LIBRARY_TYPES = ["movies", "music", "tvshows"] + +PLAYABLE_MEDIA_TYPES = [ + MediaType.EPISODE, + MediaType.MOVIE, + MediaType.MUSIC, +] + + +async def item_payload( + hass: HomeAssistant, + client: JellyfinClient, + user_id: str, + item: dict[str, Any], +) -> BrowseMedia: + """Create response payload for a single media item.""" + title = item["Name"] + thumbnail = get_artwork_url(client, item, 600) + + media_content_id = item["Id"] + media_content_type = CONTENT_TYPE_MAP.get(item["Type"], MEDIA_TYPE_NONE) + + return BrowseMedia( + title=title, + media_content_id=media_content_id, + media_content_type=media_content_type, + media_class=MEDIA_CLASS_MAP.get(item["Type"], MediaClass.DIRECTORY), + can_play=bool(media_content_type in PLAYABLE_MEDIA_TYPES and media_content_id), + can_expand=bool(item.get("IsFolder")), + children_media_class=None, + thumbnail=thumbnail, + ) + + +async def build_root_response( + hass: HomeAssistant, client: JellyfinClient, user_id: str +) -> BrowseMedia: + """Create response payload for root folder.""" + folders = await hass.async_add_executor_job(client.jellyfin.get_media_folders) + + children = [ + await item_payload(hass, client, user_id, folder) + for folder in folders["Items"] + if folder["CollectionType"] in JF_SUPPORTED_LIBRARY_TYPES + ] + + return BrowseMedia( + media_content_id="", + media_content_type="root", + media_class=MediaClass.DIRECTORY, + children_media_class=MediaClass.DIRECTORY, + title="Jellyfin", + can_play=False, + can_expand=True, + children=children, + ) + + +async def build_item_response( + hass: HomeAssistant, + client: JellyfinClient, + user_id: str, + media_content_type: str | None, + media_content_id: str, +) -> BrowseMedia: + """Create response payload for the provided media query.""" + title, media, thumbnail = await get_media_info( + hass, client, user_id, media_content_type, media_content_id + ) + + if title is None or media is None: + raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}") + + children = await asyncio.gather( + *(item_payload(hass, client, user_id, media_item) for media_item in media) + ) + + response = BrowseMedia( + media_class=CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS.get( + str(media_content_type), MediaClass.DIRECTORY + ), + media_content_id=media_content_id, + media_content_type=str(media_content_type), + title=title, + can_play=bool(media_content_type in PLAYABLE_MEDIA_TYPES and media_content_id), + can_expand=True, + children=children, + thumbnail=thumbnail, + ) + + response.calculate_children_class() + + return response + + +def fetch_item(client: JellyfinClient, item_id: str) -> dict[str, Any] | None: + """Fetch item from Jellyfin server.""" + result = client.jellyfin.get_item(item_id) + + if not result: + return None + + item: dict[str, Any] = result + return item + + +def fetch_items( + client: JellyfinClient, + params: dict[str, Any], +) -> list[dict[str, Any]] | None: + """Fetch items from Jellyfin server.""" + result = client.jellyfin.user_items(params=params) + + if not result or "Items" not in result or len(result["Items"]) < 1: + return None + + items: list[dict[str, Any]] = result["Items"] + + return [ + item + for item in items + if not item.get("IsFolder") + or (item.get("IsFolder") and item.get("ChildCount", 1) > 0) + ] + + +async def get_media_info( + hass: HomeAssistant, + client: JellyfinClient, + user_id: str, + media_content_type: str | None, + media_content_id: str, +) -> tuple[str | None, list[dict[str, Any]] | None, str | None]: + """Fetch media info.""" + thumbnail: str | None = None + title: str | None = None + media: list[dict[str, Any]] | None = None + + item = await hass.async_add_executor_job(fetch_item, client, media_content_id) + + if item is None: + return None, None, None + + title = item["Name"] + thumbnail = get_artwork_url(client, item) + + if item.get("IsFolder"): + media = await hass.async_add_executor_job( + fetch_items, client, {"parentId": media_content_id, "fields": "childCount"} + ) + + if not media or len(media) == 0: + media = None + + return title, media, thumbnail diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py index 9f6380e218125491d44bf2a3df3788704d7df4bc..ab771d405ea60d4372efd04c4755103edf51a0a5 100644 --- a/homeassistant/components/jellyfin/client_wrapper.py +++ b/homeassistant/components/jellyfin/client_wrapper.py @@ -3,7 +3,6 @@ from __future__ import annotations import socket from typing import Any -import uuid from jellyfin_apiclient_python import Jellyfin, JellyfinClient from jellyfin_apiclient_python.api import API @@ -16,56 +15,62 @@ from homeassistant import exceptions from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import CLIENT_VERSION, USER_AGENT, USER_APP_NAME +from .const import CLIENT_VERSION, ITEM_KEY_IMAGE_TAGS, USER_AGENT, USER_APP_NAME async def validate_input( hass: HomeAssistant, user_input: dict[str, Any], client: JellyfinClient -) -> str: +) -> tuple[str, dict[str, Any]]: """Validate that the provided url and credentials can be used to connect.""" url = user_input[CONF_URL] username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] - userid = await hass.async_add_executor_job( + user_id, connect_result = await hass.async_add_executor_job( _connect, client, url, username, password ) - return userid + return (user_id, connect_result) -def create_client() -> JellyfinClient: +def create_client(device_id: str, device_name: str | None = None) -> JellyfinClient: """Create a new Jellyfin client.""" - jellyfin = Jellyfin() - client = jellyfin.get_client() - _setup_client(client) - return client - + if device_name is None: + device_name = socket.gethostname() -def _setup_client(client: JellyfinClient) -> None: - """Configure the Jellyfin client with a number of required properties.""" - player_name = socket.gethostname() - client_uuid = str(uuid.uuid4()) + jellyfin = Jellyfin() - client.config.app(USER_APP_NAME, CLIENT_VERSION, player_name, client_uuid) + client = jellyfin.get_client() + client.config.app(USER_APP_NAME, CLIENT_VERSION, device_name, device_id) client.config.http(USER_AGENT) + return client + -def _connect(client: JellyfinClient, url: str, username: str, password: str) -> str: +def _connect( + client: JellyfinClient, url: str, username: str, password: str +) -> tuple[str, dict[str, Any]]: """Connect to the Jellyfin server and assert that the user can login.""" client.config.data["auth.ssl"] = url.startswith("https") - _connect_to_address(client.auth, url) + connect_result = _connect_to_address(client.auth, url) + _login(client.auth, url, username, password) - return _get_id(client.jellyfin) + + return (_get_user_id(client.jellyfin), connect_result) -def _connect_to_address(connection_manager: ConnectionManager, url: str) -> None: +def _connect_to_address( + connection_manager: ConnectionManager, url: str +) -> dict[str, Any]: """Connect to the Jellyfin server.""" - state = connection_manager.connect_to_address(url) - if state["State"] != CONNECTION_STATE["ServerSignIn"]: + result: dict[str, Any] = connection_manager.connect_to_address(url) + + if result["State"] != CONNECTION_STATE["ServerSignIn"]: raise CannotConnect + return result + def _login( connection_manager: ConnectionManager, @@ -75,17 +80,37 @@ def _login( ) -> None: """Assert that the user can log in to the Jellyfin server.""" response = connection_manager.login(url, username, password) + if "AccessToken" not in response: raise InvalidAuth -def _get_id(api: API) -> str: +def _get_user_id(api: API) -> str: """Set the unique userid from a Jellyfin server.""" settings: dict[str, Any] = api.get_user_settings() userid: str = settings["Id"] return userid +def get_artwork_url( + client: JellyfinClient, item: dict[str, Any], max_width: int = 600 +) -> str | None: + """Find a suitable thumbnail for an item.""" + artwork_id: str = item["Id"] + artwork_type = "Primary" + parent_backdrop_id: str | None = item.get("ParentBackdropItemId") + + if "Backdrop" in item[ITEM_KEY_IMAGE_TAGS]: + artwork_type = "Backdrop" + elif parent_backdrop_id: + artwork_type = "Backdrop" + artwork_id = parent_backdrop_id + elif "Primary" not in item[ITEM_KEY_IMAGE_TAGS]: + return None + + return str(client.jellyfin.artwork(artwork_id, artwork_type, max_width)) + + class CannotConnect(exceptions.HomeAssistantError): """Error to indicate the server is unreachable.""" diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index 6820031ed7b0692e53269e137ece44ab033e336b..84b78d51926fccb61c81803ee5c712bdf2a0cbaa 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -9,9 +9,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult +from homeassistant.util.uuid import random_uuid_hex from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input -from .const import DOMAIN +from .const import CONF_CLIENT_DEVICE_ID, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -24,11 +25,20 @@ STEP_USER_DATA_SCHEMA = vol.Schema( ) +def _generate_client_device_id() -> str: + """Generate a random UUID4 string to identify ourselves.""" + return random_uuid_hex() + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Jellyfin.""" VERSION = 1 + def __init__(self) -> None: + """Initialize the Jellyfin config flow.""" + self.client_device_id: str | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -39,9 +49,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - client = create_client() + if self.client_device_id is None: + self.client_device_id = _generate_client_device_id() + + client = create_client(device_id=self.client_device_id) try: - userid = await validate_input(self.hass, user_input, client) + user_id, connect_result = await validate_input( + self.hass, user_input, client + ) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -50,11 +65,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" _LOGGER.exception(ex) else: - await self.async_set_unique_id(userid) + entry_title = user_input[CONF_URL] + + server_info: dict[str, Any] = connect_result["Servers"][0] + + if server_name := server_info.get("Name"): + entry_title = server_name + + await self.async_set_unique_id(user_id) self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_input[CONF_URL], data=user_input + title=entry_title, + data={CONF_CLIENT_DEVICE_ID: self.client_device_id, **user_input}, ) return self.async_show_form( diff --git a/homeassistant/components/jellyfin/const.py b/homeassistant/components/jellyfin/const.py index 1f679fd43c8dc3bf584ee0a6a2b32f453f6c609d..865e05a00817c7919b2ce611192772ffe3845e72 100644 --- a/homeassistant/components/jellyfin/const.py +++ b/homeassistant/components/jellyfin/const.py @@ -1,15 +1,20 @@ """Constants for the Jellyfin integration.""" - +import logging from typing import Final +from homeassistant.components.media_player import MediaClass, MediaType +from homeassistant.const import Platform, __version__ as hass_version + DOMAIN: Final = "jellyfin" -CLIENT_VERSION: Final = "1.0" +CLIENT_VERSION: Final = hass_version COLLECTION_TYPE_MOVIES: Final = "movies" COLLECTION_TYPE_MUSIC: Final = "music" -DATA_CLIENT: Final = "client" +CONF_CLIENT_DEVICE_ID: Final = "client_device_id" + +DEFAULT_NAME: Final = "Jellyfin" ITEM_KEY_COLLECTION_TYPE: Final = "CollectionType" ITEM_KEY_ID: Final = "Id" @@ -28,7 +33,6 @@ ITEM_TYPE_MOVIE: Final = "Movie" MAX_IMAGE_WIDTH: Final = 500 MAX_STREAMING_BITRATE: Final = "140000000" - MEDIA_SOURCE_KEY_PATH: Final = "Path" MEDIA_TYPE_AUDIO: Final = "Audio" @@ -39,3 +43,30 @@ SUPPORTED_COLLECTION_TYPES: Final = [COLLECTION_TYPE_MUSIC, COLLECTION_TYPE_MOVI USER_APP_NAME: Final = "Home Assistant" USER_AGENT: Final = f"Home-Assistant/{CLIENT_VERSION}" + +CONTENT_TYPE_MAP = { + "Audio": MediaType.MUSIC, + "Episode": MediaType.EPISODE, + "Season": MediaType.SEASON, + "Series": MediaType.TVSHOW, + "Movie": MediaType.MOVIE, + "CollectionFolder": "collection", + "AggregateFolder": "library", + "Folder": "library", + "BoxSet": "boxset", +} +MEDIA_CLASS_MAP = { + "MusicAlbum": MediaClass.ALBUM, + "MusicArtist": MediaClass.ARTIST, + "Audio": MediaClass.MUSIC, + "Series": MediaClass.DIRECTORY, + "Movie": MediaClass.MOVIE, + "CollectionFolder": MediaClass.DIRECTORY, + "Folder": MediaClass.DIRECTORY, + "BoxSet": MediaClass.DIRECTORY, + "Episode": MediaClass.EPISODE, + "Season": MediaClass.SEASON, +} + +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR] +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/jellyfin/coordinator.py b/homeassistant/components/jellyfin/coordinator.py new file mode 100644 index 0000000000000000000000000000000000000000..626b2126fee51c892928ac700025bd56926f66e5 --- /dev/null +++ b/homeassistant/components/jellyfin/coordinator.py @@ -0,0 +1,74 @@ +"""Data update coordinator for the Jellyfin integration.""" +from __future__ import annotations + +from abc import abstractmethod +from datetime import timedelta +from typing import Any, TypeVar, Union + +from jellyfin_apiclient_python import JellyfinClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER + +JellyfinDataT = TypeVar( + "JellyfinDataT", + bound=Union[ + dict[str, dict[str, Any]], + dict[str, Any], + ], +) + + +class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[JellyfinDataT]): + """Data update coordinator for the Jellyfin integration.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + api_client: JellyfinClient, + system_info: dict[str, Any], + user_id: str, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=10), + ) + self.api_client: JellyfinClient = api_client + self.server_id: str = system_info["Id"] + self.server_name: str = system_info["Name"] + self.server_version: str | None = system_info.get("Version") + self.user_id: str = user_id + + async def _async_update_data(self) -> JellyfinDataT: + """Get the latest data from Jellyfin.""" + return await self._fetch_data() + + @abstractmethod + async def _fetch_data(self) -> JellyfinDataT: + """Fetch the actual data.""" + + +class SessionsDataUpdateCoordinator( + JellyfinDataUpdateCoordinator[dict[str, dict[str, Any]]] +): + """Sessions update coordinator for Jellyfin.""" + + async def _fetch_data(self) -> dict[str, dict[str, Any]]: + """Fetch the data.""" + sessions = await self.hass.async_add_executor_job( + self.api_client.jellyfin.sessions + ) + + sessions_by_id: dict[str, dict[str, Any]] = { + session["Id"]: session for session in sessions + } + + return sessions_by_id diff --git a/homeassistant/components/jellyfin/diagnostics.py b/homeassistant/components/jellyfin/diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..36b2882fbebc13d81ef2d0a13ead991496fec434 --- /dev/null +++ b/homeassistant/components/jellyfin/diagnostics.py @@ -0,0 +1,48 @@ +"""Diagnostics support for Jellyfin.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .models import JellyfinData + +TO_REDACT = {CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data: JellyfinData = hass.data[DOMAIN][entry.entry_id] + sessions = data.coordinators["sessions"] + + return { + "entry": { + "title": entry.title, + "data": async_redact_data(entry.data, TO_REDACT), + }, + "server": { + "id": sessions.server_id, + "name": sessions.server_name, + "version": sessions.server_version, + }, + "sessions": [ + { + "id": session_id, + "user_id": session_data.get("UserId"), + "device_id": session_data.get("DeviceId"), + "device_name": session_data.get("DeviceName"), + "client_name": session_data.get("Client"), + "client_version": session_data.get("ApplicationVersion"), + "capabilities": session_data.get("Capabilities"), + "now_playing": session_data.get("NowPlayingItem"), + "play_state": session_data.get("PlayState"), + } + for session_id, session_data in sessions.data.items() + ], + } diff --git a/homeassistant/components/jellyfin/entity.py b/homeassistant/components/jellyfin/entity.py new file mode 100644 index 0000000000000000000000000000000000000000..eb74b5d5c51ab9ab228098cb13a68420c34d2a31 --- /dev/null +++ b/homeassistant/components/jellyfin/entity.py @@ -0,0 +1,33 @@ +"""Base Entity for Jellyfin.""" +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import JellyfinDataT, JellyfinDataUpdateCoordinator + + +class JellyfinEntity(CoordinatorEntity[JellyfinDataUpdateCoordinator[JellyfinDataT]]): + """Defines a base Jellyfin entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: JellyfinDataUpdateCoordinator[JellyfinDataT], + description: EntityDescription, + ) -> None: + """Initialize the Jellyfin entity.""" + super().__init__(coordinator) + self.coordinator = coordinator + self.entity_description = description + self._attr_unique_id = f"{coordinator.server_id}-{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self.coordinator.server_id)}, + manufacturer=DEFAULT_NAME, + name=self.coordinator.server_name, + sw_version=self.coordinator.server_version, + ) diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index 48f4cf0c837aa104c076f742afb8214e57de132f..6c2cdb98ae43307eb964e72c0b6be7739e06c442 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -3,8 +3,9 @@ "name": "Jellyfin", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/jellyfin", - "requirements": ["jellyfin-apiclient-python==1.8.1"], + "integration_type": "service", + "requirements": ["jellyfin-apiclient-python==1.9.2"], "iot_class": "local_polling", - "codeowners": ["@j-stienstra"], + "codeowners": ["@j-stienstra", "@ctalkington"], "loggers": ["jellyfin_apiclient_python"] } diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py new file mode 100644 index 0000000000000000000000000000000000000000..36fb65916d28d4447b8bb5eaa563c3f74545a7cc --- /dev/null +++ b/homeassistant/components/jellyfin/media_player.py @@ -0,0 +1,295 @@ +"""Support for the Jellyfin media player.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.media_player import ( + MediaPlayerEntity, + MediaPlayerEntityDescription, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, +) +from homeassistant.components.media_player.browse_media import BrowseMedia +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.dt import parse_datetime + +from .browse_media import build_item_response, build_root_response +from .client_wrapper import get_artwork_url +from .const import CONTENT_TYPE_MAP, DOMAIN, USER_APP_NAME +from .coordinator import JellyfinDataUpdateCoordinator +from .entity import JellyfinEntity +from .models import JellyfinData + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Jellyfin media_player from a config entry.""" + jellyfin_data: JellyfinData = hass.data[DOMAIN][entry.entry_id] + coordinator = jellyfin_data.coordinators["sessions"] + + async_add_entities( + ( + JellyfinMediaPlayer(coordinator, session_id, session_data) + for session_id, session_data in coordinator.data.items() + if session_data["DeviceId"] != jellyfin_data.client_device_id + and session_data["Client"] != USER_APP_NAME + ), + ) + + +class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity): + """Represents a Jellyfin Player device.""" + + def __init__( + self, + coordinator: JellyfinDataUpdateCoordinator, + session_id: str, + session_data: dict[str, Any], + ) -> None: + """Initialize the Jellyfin Media Player entity.""" + super().__init__( + coordinator, + MediaPlayerEntityDescription( + key=session_id, + ), + ) + + self.session_id = session_id + self.session_data: dict[str, Any] | None = session_data + self.device_id: str = session_data["DeviceId"] + self.device_name: str = session_data["DeviceName"] + self.client_name: str = session_data["Client"] + self.app_version: str = session_data["ApplicationVersion"] + + self.capabilities: dict[str, Any] = session_data["Capabilities"] + self.now_playing: dict[str, Any] | None = session_data.get("NowPlayingItem") + self.play_state: dict[str, Any] | None = session_data.get("PlayState") + + if self.capabilities.get("SupportsPersistentIdentifier", False): + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.device_id)}, + manufacturer="Jellyfin", + model=self.client_name, + name=self.device_name, + sw_version=self.app_version, + via_device=(DOMAIN, coordinator.server_id), + ) + else: + self._attr_device_info = None + self._attr_has_entity_name = False + self._attr_name = self.device_name + + self._update_from_session_data() + + @callback + def _handle_coordinator_update(self) -> None: + self.session_data = ( + self.coordinator.data.get(self.session_id) + if self.coordinator.data is not None + else None + ) + + if self.session_data is not None: + self.now_playing = self.session_data.get("NowPlayingItem") + self.play_state = self.session_data.get("PlayState") + else: + self.now_playing = None + self.play_state = None + + self._update_from_session_data() + super()._handle_coordinator_update() + + @callback + def _update_from_session_data(self) -> None: + """Process session data to update entity properties.""" + state = None + media_content_type = None + media_content_id = None + media_title = None + media_series_title = None + media_season = None + media_episode = None + media_album_name = None + media_album_artist = None + media_artist = None + media_track = None + media_duration = None + media_position = None + media_position_updated = None + volume_muted = False + volume_level = None + + if self.session_data is not None: + state = MediaPlayerState.IDLE + media_position_updated = ( + parse_datetime(self.session_data["LastPlaybackCheckIn"]) + if self.now_playing + else None + ) + + if self.now_playing is not None: + state = MediaPlayerState.PLAYING + media_content_type = CONTENT_TYPE_MAP.get(self.now_playing["Type"], None) + media_content_id = self.now_playing["Id"] + media_title = self.now_playing["Name"] + media_duration = int(self.now_playing["RunTimeTicks"] / 10000000) + + if media_content_type == MediaType.EPISODE: + media_content_type = MediaType.TVSHOW + media_series_title = self.now_playing.get("SeriesName") + media_season = self.now_playing.get("ParentIndexNumber") + media_episode = self.now_playing.get("IndexNumber") + elif media_content_type == MediaType.MUSIC: + media_album_name = self.now_playing.get("Album") + media_album_artist = self.now_playing.get("AlbumArtist") + media_track = self.now_playing.get("IndexNumber") + if media_artists := self.now_playing.get("Artists"): + media_artist = str(media_artists[0]) + + if self.play_state is not None: + if self.play_state.get("IsPaused"): + state = MediaPlayerState.PAUSED + + media_position = ( + int(self.play_state["PositionTicks"] / 10000000) + if "PositionTicks" in self.play_state + else None + ) + volume_muted = bool(self.play_state.get("IsMuted", False)) + volume_level = ( + float(self.play_state["VolumeLevel"] / 100) + if "VolumeLevel" in self.play_state + else None + ) + + self._attr_state = state + self._attr_is_volume_muted = volume_muted + self._attr_volume_level = volume_level + self._attr_media_content_type = media_content_type + self._attr_media_content_id = media_content_id + self._attr_media_title = media_title + self._attr_media_series_title = media_series_title + self._attr_media_season = media_season + self._attr_media_episode = media_episode + self._attr_media_album_name = media_album_name + self._attr_media_album_artist = media_album_artist + self._attr_media_artist = media_artist + self._attr_media_track = media_track + self._attr_media_duration = media_duration + self._attr_media_position = media_position + self._attr_media_position_updated_at = media_position_updated + self._attr_media_image_remotely_accessible = True + + @property + def media_image_url(self) -> str | None: + """Image url of current playing media.""" + # We always need the now playing item. + # If there is none, there's also no url + if self.now_playing is None: + return None + + return get_artwork_url(self.coordinator.api_client, self.now_playing, 150) + + @property + def supported_features(self) -> int: + """Flag media player features that are supported.""" + commands: list[str] = self.capabilities.get("SupportedCommands", []) + controllable = self.capabilities.get("SupportsMediaControl", False) + features = 0 + + if controllable: + features |= ( + MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.SEEK + ) + + if "Mute" in commands: + features |= MediaPlayerEntityFeature.VOLUME_MUTE + + if "VolumeSet" in commands: + features |= MediaPlayerEntityFeature.VOLUME_SET + + return features + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success and self.session_data is not None + + def media_seek(self, position: float) -> None: + """Send seek command.""" + self.coordinator.api_client.jellyfin.remote_seek( + self.session_id, int(position * 10000000) + ) + + def media_pause(self) -> None: + """Send pause command.""" + self.coordinator.api_client.jellyfin.remote_pause(self.session_id) + self._attr_state = MediaPlayerState.PAUSED + + def media_play(self) -> None: + """Send play command.""" + self.coordinator.api_client.jellyfin.remote_unpause(self.session_id) + self._attr_state = MediaPlayerState.PLAYING + + def media_play_pause(self) -> None: + """Send the PlayPause command to the session.""" + self.coordinator.api_client.jellyfin.remote_playpause(self.session_id) + + def media_stop(self) -> None: + """Send stop command.""" + self.coordinator.api_client.jellyfin.remote_stop(self.session_id) + self._attr_state = MediaPlayerState.IDLE + + def play_media( + self, media_type: str, media_id: str, **kwargs: dict[str, Any] + ) -> None: + """Play a piece of media.""" + self.coordinator.api_client.jellyfin.remote_play_media( + self.session_id, [media_id] + ) + + def set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + self.coordinator.api_client.jellyfin.remote_set_volume( + self.session_id, int(volume * 100) + ) + + def mute_volume(self, mute: bool) -> None: + """Mute the volume.""" + if mute: + self.coordinator.api_client.jellyfin.remote_mute(self.session_id) + else: + self.coordinator.api_client.jellyfin.remote_unmute(self.session_id) + + async def async_browse_media( + self, media_content_type: str | None = None, media_content_id: str | None = None + ) -> BrowseMedia: + """Return a BrowseMedia instance. + + The BrowseMedia instance will be used by the "media_player/browse_media" websocket command. + + """ + if media_content_id is None or media_content_id == "media-source://jellyfin": + return await build_root_response( + self.hass, self.coordinator.api_client, self.coordinator.user_id + ) + + return await build_item_response( + self.hass, + self.coordinator.api_client, + self.coordinator.user_id, + media_content_type, + media_content_id, + ) diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index 2cb211acb9b2ecd6452530248c24e0097cd6b0fc..dfb5bd8292406c1b4c9bbc8c89ad2bb2dd233033 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -1,7 +1,6 @@ """The Media Source implementation for the Jellyfin integration.""" from __future__ import annotations -import logging import mimetypes from typing import Any @@ -20,7 +19,6 @@ from homeassistant.core import HomeAssistant from .const import ( COLLECTION_TYPE_MOVIES, COLLECTION_TYPE_MUSIC, - DATA_CLIENT, DOMAIN, ITEM_KEY_COLLECTION_TYPE, ITEM_KEY_ID, @@ -41,19 +39,16 @@ from .const import ( MEDIA_TYPE_VIDEO, SUPPORTED_COLLECTION_TYPES, ) - -_LOGGER = logging.getLogger(__name__) +from .models import JellyfinData async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up Jellyfin media source.""" # Currently only a single Jellyfin server is supported entry = hass.config_entries.async_entries(DOMAIN)[0] + jellyfin_data: JellyfinData = hass.data[DOMAIN][entry.entry_id] - data = hass.data[DOMAIN][entry.entry_id] - client: JellyfinClient = data[DATA_CLIENT] - - return JellyfinSource(hass, client) + return JellyfinSource(hass, jellyfin_data.jellyfin_client) class JellyfinSource(MediaSource): diff --git a/homeassistant/components/jellyfin/models.py b/homeassistant/components/jellyfin/models.py new file mode 100644 index 0000000000000000000000000000000000000000..b63650421274cc1d0f846d9657e81e51838434dc --- /dev/null +++ b/homeassistant/components/jellyfin/models.py @@ -0,0 +1,17 @@ +"""Models for the Jellyfin integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from jellyfin_apiclient_python import JellyfinClient + +from .coordinator import JellyfinDataUpdateCoordinator + + +@dataclass +class JellyfinData: + """Data for the Jellyfin integration.""" + + client_device_id: str + jellyfin_client: JellyfinClient + coordinators: dict[str, JellyfinDataUpdateCoordinator] diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..1957adfc6eb12d72a036099d93a71bef30da27d7 --- /dev/null +++ b/homeassistant/components/jellyfin/sensor.py @@ -0,0 +1,74 @@ +"""Support for Jellyfin sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .coordinator import JellyfinDataT +from .entity import JellyfinEntity +from .models import JellyfinData + + +@dataclass +class JellyfinSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[JellyfinDataT], StateType] + + +@dataclass +class JellyfinSensorEntityDescription( + SensorEntityDescription, JellyfinSensorEntityDescriptionMixin +): + """Describes Jellyfin sensor entity.""" + + +def _count_now_playing(data: JellyfinDataT) -> int: + """Count the number of now playing.""" + session_ids = [ + sid for (sid, session) in data.items() if "NowPlayingItem" in session + ] + + return len(session_ids) + + +SENSOR_TYPES: dict[str, JellyfinSensorEntityDescription] = { + "sessions": JellyfinSensorEntityDescription( + key="watching", + icon="mdi:television-play", + native_unit_of_measurement="Watching", + value_fn=_count_now_playing, + ) +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Jellyfin sensor based on a config entry.""" + jellyfin_data: JellyfinData = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + JellyfinSensor(jellyfin_data.coordinators[coordinator_type], description) + for coordinator_type, description in SENSOR_TYPES.items() + ) + + +class JellyfinSensor(JellyfinEntity, SensorEntity): + """Defines a Jellyfin sensor entity.""" + + entity_description: JellyfinSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/jellyfin/translations/nb.json b/homeassistant/components/jellyfin/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/jellyfin/translations/nb.json +++ b/homeassistant/components/jellyfin/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/juicenet/translations/nb.json b/homeassistant/components/juicenet/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/juicenet/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/justnimbus/translations/he.json b/homeassistant/components/justnimbus/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..ec7547d54054f6b425d8fa6cae8215915c49324d --- /dev/null +++ b/homeassistant/components/justnimbus/translations/he.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "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": { + "user": { + "data": { + "client_id": "\u05de\u05d6\u05d4\u05d4 \u05dc\u05e7\u05d5\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/justnimbus/translations/nb.json b/homeassistant/components/justnimbus/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/justnimbus/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kaleidescape/translations/nb.json b/homeassistant/components/kaleidescape/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/kaleidescape/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index a2b01040a5a80ec6b53aea3d333e079019738cf6..4491b644b27b6f35473eab1a1ffd0992888626b0 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -64,8 +64,7 @@ async def async_setup_entry( ) ) - if restored: - async_add_entities(restored) + async_add_entities(restored) async_dispatcher_connect(hass, router.signal_update, update_from_router) @@ -79,8 +78,7 @@ def update_items(router: KeeneticRouter, async_add_entities, tracked: set[str]): tracked.add(mac) new_tracked.append(KeeneticTracker(device, router)) - if new_tracked: - async_add_entities(new_tracked) + async_add_entities(new_tracked) class KeeneticTracker(ScannerEntity): diff --git a/homeassistant/components/kegtron/translations/et.json b/homeassistant/components/kegtron/translations/et.json new file mode 100644 index 0000000000000000000000000000000000000000..170815ec87ee5f51f2b3c01791aaef15f2000b41 --- /dev/null +++ b/homeassistant/components/kegtron/translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "already_in_progress": "Seadistamine on juba k\u00e4imas", + "no_devices_found": "V\u00f5rgust seadmeid ei leitud", + "not_supported": "Seadet ei toetata" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Kas seadistada {name}?" + }, + "user": { + "data": { + "address": "Seade" + }, + "description": "Vali h\u00e4\u00e4lestatav seade" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kegtron/translations/hu.json b/homeassistant/components/kegtron/translations/hu.json index 97fbb5b940835353812ebfe6bb39907626ddb6e9..4668ffea41696296cf59192c6561163e058b2a49 100644 --- a/homeassistant/components/kegtron/translations/hu.json +++ b/homeassistant/components/kegtron/translations/hu.json @@ -15,7 +15,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/kegtron/translations/ja.json b/homeassistant/components/kegtron/translations/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..7e4f5db8e3beaae476d94b322f3b676a5ae20ca5 --- /dev/null +++ b/homeassistant/components/kegtron/translations/ja.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\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", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "not_supported": "\u30c7\u30d0\u30a4\u30b9\u304c\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u307e\u305b\u3093" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "{name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "address": "\u30c7\u30d0\u30a4\u30b9" + }, + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kegtron/translations/nb.json b/homeassistant/components/kegtron/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..6ba5a1f39783f4c2bf505ed9ee81367229b702eb --- /dev/null +++ b/homeassistant/components/kegtron/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kegtron/translations/pt-BR.json b/homeassistant/components/kegtron/translations/pt-BR.json index 0da7639fa2a93843aa8bda468ee27aa71d07f405..5b65416320111def474e9d53852a11cf24050202 100644 --- a/homeassistant/components/kegtron/translations/pt-BR.json +++ b/homeassistant/components/kegtron/translations/pt-BR.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", - "already_in_progress": "A configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", "no_devices_found": "Nenhum dispositivo encontrado na rede", "not_supported": "Dispositivo n\u00e3o suportado" }, diff --git a/homeassistant/components/kegtron/translations/sv.json b/homeassistant/components/kegtron/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..6c6f3f5f1bbaac390c3e8d23dd4186c61d8259ee --- /dev/null +++ b/homeassistant/components/kegtron/translations/sv.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad", + "already_in_progress": "Konfigurationsfl\u00f6det p\u00e5g\u00e5r redan", + "no_devices_found": "Inga enheter hittades i n\u00e4tverket", + "not_supported": "Enheten st\u00f6ds inte" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Vill du konfigurera {name}?" + }, + "user": { + "data": { + "address": "Enhet" + }, + "description": "V\u00e4lj en enhet att konfigurera" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kegtron/translations/tr.json b/homeassistant/components/kegtron/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..f0ddbc274c94333b7cbd115388cd957e16e4989c --- /dev/null +++ b/homeassistant/components/kegtron/translations/tr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "not_supported": "Cihaz desteklenmiyor" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "{name} kurulumunu yapmak istiyor musunuz?" + }, + "user": { + "data": { + "address": "Cihaz" + }, + "description": "Kurulum i\u00e7in bir cihaz se\u00e7in" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keymitt_ble/translations/et.json b/homeassistant/components/keymitt_ble/translations/et.json new file mode 100644 index 0000000000000000000000000000000000000000..9ebb2faadb99706448f2015bc084b6d3ba1f4720 --- /dev/null +++ b/homeassistant/components/keymitt_ble/translations/et.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured_device": "Seade on juba h\u00e4\u00e4lestatud", + "cannot_connect": "\u00dchendamine nurjus", + "no_unconfigured_devices": "H\u00e4\u00e4lestamata seadmeid ei leitud.", + "unknown": "Ootamatu t\u00f5rge" + }, + "error": { + "linking": "Sidumine eba\u00f5nnestus, proovi uuesti. Kas MicroBot on sidumisre\u017eiimis?" + }, + "flow_title": "{name}", + "step": { + "init": { + "data": { + "address": "Seadme aadress", + "name": "Nimi" + }, + "title": "MicroBot seadme seadistamine" + }, + "link": { + "description": "Vajutage MicroBot Push'i nuppu, kui LED on roosa v\u00f5i roheline, et registreeruda Home Assistant'is.", + "title": "Sidumine" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keymitt_ble/translations/ja.json b/homeassistant/components/keymitt_ble/translations/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..529dea51c30be00dfc5f40029a7f8c3996a86618 --- /dev/null +++ b/homeassistant/components/keymitt_ble/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured_device": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "no_unconfigured_devices": "\u672a\u69cb\u6210\u306e\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name}", + "step": { + "init": { + "data": { + "name": "\u540d\u524d" + } + }, + "link": { + "title": "\u30da\u30a2\u30ea\u30f3\u30b0" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keymitt_ble/translations/nb.json b/homeassistant/components/keymitt_ble/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..08fc754319a4c43015ffdfe910f6f2001b0abe15 --- /dev/null +++ b/homeassistant/components/keymitt_ble/translations/nb.json @@ -0,0 +1,9 @@ +{ + "config": { + "abort": { + "already_configured_device": "Enheten er allerede konfigurert", + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keymitt_ble/translations/pt-BR.json b/homeassistant/components/keymitt_ble/translations/pt-BR.json index 66e44612afe19992010b3db552791a321ae3a345..a04c7d7f90fe4ae3c33fea4e52d8dfa6cac2866d 100644 --- a/homeassistant/components/keymitt_ble/translations/pt-BR.json +++ b/homeassistant/components/keymitt_ble/translations/pt-BR.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "already_configured_device": "O dispositivo j\u00e1 est\u00e1 configurado", - "cannot_connect": "Falhou ao conectar", + "already_configured_device": "Dispositivo j\u00e1 est\u00e1 configurado", + "cannot_connect": "Falha ao conectar", "no_unconfigured_devices": "Nenhum dispositivo n\u00e3o configurado encontrado.", "unknown": "Erro inesperado" }, diff --git a/homeassistant/components/keymitt_ble/translations/sv.json b/homeassistant/components/keymitt_ble/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..d7b16419bf0bde54dc1bcb92ebd5312b9899de2d --- /dev/null +++ b/homeassistant/components/keymitt_ble/translations/sv.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured_device": "Enheten \u00e4r redan konfigurerad", + "cannot_connect": "Det gick inte att ansluta.", + "no_unconfigured_devices": "Inga okonfigurerade enheter hittades.", + "unknown": "Ov\u00e4ntat fel" + }, + "error": { + "linking": "Det gick inte att koppla ihop, f\u00f6rs\u00f6k igen. \u00c4r MicroBot i parningsl\u00e4ge?" + }, + "flow_title": "{name}", + "step": { + "init": { + "data": { + "address": "Enhetsadress", + "name": "Namn" + }, + "title": "Konfigurera MicroBot-enhet" + }, + "link": { + "description": "Tryck p\u00e5 knappen p\u00e5 MicroBot Push n\u00e4r lysdioden lyser rosa eller gr\u00f6nt f\u00f6r att registrera dig med Home Assistant.", + "title": "Parkoppling" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/keymitt_ble/translations/tr.json b/homeassistant/components/keymitt_ble/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..49b3f7d917c6fefe0b236efc3ced1d5981bc5814 --- /dev/null +++ b/homeassistant/components/keymitt_ble/translations/tr.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured_device": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Ba\u011flanma hatas\u0131", + "no_unconfigured_devices": "Yap\u0131land\u0131r\u0131lmam\u0131\u015f cihaz bulunamad\u0131.", + "unknown": "Beklenmeyen hata" + }, + "error": { + "linking": "E\u015fle\u015ftirilemedi, l\u00fctfen tekrar deneyin. MicroBot e\u015fle\u015ftirme modunda m\u0131?" + }, + "flow_title": "{name}", + "step": { + "init": { + "data": { + "address": "Cihaz adresi", + "name": "Ad" + }, + "title": "MicroBot cihaz\u0131n\u0131 kurun" + }, + "link": { + "description": "Home Assistant'a kaydolmak i\u00e7in LED sabit pembe veya ye\u015fil oldu\u011funda MicroBot Push'taki d\u00fc\u011fmeye bas\u0131n.", + "title": "E\u015fle\u015ftirme" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/nb.json b/homeassistant/components/kmtronic/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/kmtronic/translations/nb.json +++ b/homeassistant/components/kmtronic/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 9268b53581b75370e173cd8ad1e90631286c40b1..e4260f5e868b8e307bed5e595a4632451bd6a99d 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -9,7 +9,7 @@ from xknx.devices.light import Light as XknxLight, XYYColor from homeassistant import config_entries from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, @@ -153,15 +153,8 @@ class KNXLight(KnxEntity, LightEntity): def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize of KNX light.""" super().__init__(_create_light(xknx, config)) - self._max_kelvin: int = config[LightSchema.CONF_MAX_KELVIN] - self._min_kelvin: int = config[LightSchema.CONF_MIN_KELVIN] - - self._attr_max_mireds = color_util.color_temperature_kelvin_to_mired( - self._min_kelvin - ) - self._attr_min_mireds = color_util.color_temperature_kelvin_to_mired( - self._max_kelvin - ) + self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN] + self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN] self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) self._attr_unique_id = self._device_unique_id() @@ -242,21 +235,23 @@ class KNXLight(KnxEntity, LightEntity): return None @property - def color_temp(self) -> int | None: - """Return the color temperature in mireds.""" + def color_temp_kelvin(self) -> int | None: + """Return the color temperature in Kelvin.""" if self._device.supports_color_temperature: - kelvin = self._device.current_color_temperature - # Avoid division by zero if actuator reported 0 Kelvin (e.g., uninitialized DALI-Gateway) - if kelvin is not None and kelvin > 0: - return color_util.color_temperature_kelvin_to_mired(kelvin) + if kelvin := self._device.current_color_temperature: + return kelvin if self._device.supports_tunable_white: relative_ct = self._device.current_tunable_white if relative_ct is not None: - # as KNX devices typically use Kelvin we use it as base for - # calculating ct from percent - return color_util.color_temperature_kelvin_to_mired( - self._min_kelvin - + ((relative_ct / 255) * (self._max_kelvin - self._min_kelvin)) + return int( + self._attr_min_color_temp_kelvin + + ( + (relative_ct / 255) + * ( + self._attr_max_color_temp_kelvin + - self._attr_min_color_temp_kelvin + ) + ) ) return None @@ -288,7 +283,7 @@ class KNXLight(KnxEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) - mireds = kwargs.get(ATTR_COLOR_TEMP) + color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) rgb = kwargs.get(ATTR_RGB_COLOR) rgbw = kwargs.get(ATTR_RGBW_COLOR) hs_color = kwargs.get(ATTR_HS_COLOR) @@ -297,7 +292,7 @@ class KNXLight(KnxEntity, LightEntity): if ( not self.is_on and brightness is None - and mireds is None + and color_temp is None and rgb is None and rgbw is None and hs_color is None @@ -335,17 +330,21 @@ class KNXLight(KnxEntity, LightEntity): await set_color(rgb, None, brightness) return - if mireds is not None: - kelvin = int(color_util.color_temperature_mired_to_kelvin(mireds)) - kelvin = min(self._max_kelvin, max(self._min_kelvin, kelvin)) - + if color_temp is not None: + color_temp = min( + self._attr_max_color_temp_kelvin, + max(self._attr_min_color_temp_kelvin, color_temp), + ) if self._device.supports_color_temperature: - await self._device.set_color_temperature(kelvin) + await self._device.set_color_temperature(color_temp) elif self._device.supports_tunable_white: - relative_ct = int( + relative_ct = round( 255 - * (kelvin - self._min_kelvin) - / (self._max_kelvin - self._min_kelvin) + * (color_temp - self._attr_min_color_temp_kelvin) + / ( + self._attr_max_color_temp_kelvin + - self._attr_min_color_temp_kelvin + ) ) await self._device.set_tunable_white(relative_ct) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 0f2f1201415e946dbcbfd5839ad753b7735e369f..b29e44490e92d221f28ed994681588c4a17be430 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -3,9 +3,10 @@ "name": "KNX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/knx", - "requirements": ["xknx==1.1.0"], + "requirements": ["xknx==1.2.0"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "quality_scale": "platinum", "iot_class": "local_push", - "loggers": ["xknx"] + "loggers": ["xknx"], + "integration_type": "hub" } diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 55602d6153af38b8b7b0f7a61798ac5eb33f4da2..c1615b7e8e2b59fe3f7cf2440d141668ac8b0812 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -22,6 +22,9 @@ from homeassistant.components.cover import ( ) from homeassistant.components.number import NumberMode from homeassistant.components.sensor import CONF_STATE_CLASS, STATE_CLASSES_SCHEMA +from homeassistant.components.switch import ( + DEVICE_CLASSES_SCHEMA as SWITCH_DEVICE_CLASSES_SCHEMA, +) from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, @@ -873,6 +876,7 @@ class SwitchSchema(KNXPlatformSchema): vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean, vol.Required(KNX_ADDRESS): ga_list_validator, vol.Optional(CONF_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_DEVICE_CLASS): SWITCH_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, } ) diff --git a/homeassistant/components/knx/services.yaml b/homeassistant/components/knx/services.yaml index 45ef6dcbd12bdc27bf3b6c977b0ead8fa6eb702b..d95a15738722f289be5fbb652d5c2e4d6ff5b2bf 100644 --- a/homeassistant/components/knx/services.yaml +++ b/homeassistant/components/knx/services.yaml @@ -18,7 +18,7 @@ send: object: type: name: "Value type" - description: "If set, the payload will not be sent as raw bytes, but encoded as given DPT. Knx sensor types are valid values (see https://www.home-assistant.io/integrations/sensor.knx)." + description: "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)." required: false example: "temperature" selector: @@ -53,7 +53,7 @@ event_register: object: type: name: "Value type" - description: "If set, the payload will be decoded as given DPT in the event data `value` key. Knx sensor types are valid values (see https://www.home-assistant.io/integrations/sensor.knx)." + description: "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)." required: false example: "2byte_float" selector: @@ -77,7 +77,7 @@ exposure_register: text: type: name: "Value type" - description: "Telegrams will be encoded as given DPT. 'binary' and all Knx sensor types are valid values (see https://www.home-assistant.io/integrations/sensor.knx)" + description: "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)" required: true example: "percentU8" selector: diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 9f4eb6fc632acd0f9f641d688eb12b4fa357dac0..81f8de815c98435934b86fcecf12d0db1352a36b 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -9,6 +9,7 @@ from xknx.devices import Switch as XknxSwitch from homeassistant import config_entries from homeassistant.components.switch import SwitchEntity from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_NAME, STATE_ON, @@ -56,6 +57,7 @@ class KNXSwitch(KnxEntity, SwitchEntity, RestoreEntity): ) ) self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_unique_id = str(self._device.switch.group_address) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/kodi/translations/nb.json b/homeassistant/components/kodi/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/kodi/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/translations/nb.json b/homeassistant/components/konnected/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/konnected/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 3a2d3445a8450448c6d22d23a0b9c25206d40c76..5f0bb8100c7973e483f44faa269e274ca5c7cbff 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -23,7 +23,6 @@ class PlenticoreRequiredKeysMixin: """A class that describes required properties for plenticore select entities.""" module_id: str - options: list[str] @dataclass @@ -65,6 +64,7 @@ async def async_setup_entry( entities = [] for description in SELECT_SETTINGS_DATA: + assert description.options is not None if description.module_id not in available_settings_data: continue needed_data_ids = { @@ -109,7 +109,6 @@ class PlenticoreDataSelect(CoordinatorEntity, SelectEntity): self.platform_name = platform_name self.module_id = description.module_id self.data_id = description.key - self._attr_options = description.options self._device_info = device_info self._attr_unique_id = f"{entry_id}_{description.module_id}" diff --git a/homeassistant/components/kostal_plenticore/translations/bg.json b/homeassistant/components/kostal_plenticore/translations/bg.json index 23968d0a06ad77049ca71bf9d26c1598e3899edd..e9dbb5a0a7d24d54cc52d7eb6f38346a2bbd5544 100644 --- a/homeassistant/components/kostal_plenticore/translations/bg.json +++ b/homeassistant/components/kostal_plenticore/translations/bg.json @@ -1,8 +1,20 @@ { "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", + "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": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/kostal_plenticore/translations/nb.json b/homeassistant/components/kostal_plenticore/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/kostal_plenticore/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/bg.json b/homeassistant/components/kraken/translations/bg.json index 3ac8e39cb8d75a60903007eca323f80bc491a311..7357576b2443893db60d5585b0ca7d118d897d99 100644 --- a/homeassistant/components/kraken/translations/bg.json +++ b/homeassistant/components/kraken/translations/bg.json @@ -2,6 +2,11 @@ "config": { "abort": { "already_configured": "\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." + }, + "step": { + "user": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u0437\u0430\u043f\u043e\u0447\u043d\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0442\u0430?" + } } } } \ No newline at end of file diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index d334fb61d1f79793df5d635dfee832e65acbe0e8..e2f028907d9cc9fdaf5138a46793559041b4af5e 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, + SensorStateClass, ) from homeassistant.const import ( CONF_DEVICE, @@ -185,6 +186,7 @@ class LaCrosseTemperature(LaCrosseSensor): """Implementation of a Lacrosse temperature sensor.""" _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = TEMP_CELSIUS @property @@ -197,6 +199,7 @@ class LaCrosseHumidity(LaCrosseSensor): """Implementation of a Lacrosse humidity sensor.""" _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT _attr_icon = "mdi:water-percent" @property diff --git a/homeassistant/components/lacrosse_view/translations/nb.json b/homeassistant/components/lacrosse_view/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/lacrosse_view/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lacrosse_view/translations/no.json b/homeassistant/components/lacrosse_view/translations/no.json index cd512e6fb8676952005ad78b56f4c17ec66d20a0..0b18e06b8c74b6568f7a07787d35f166120823e7 100644 --- a/homeassistant/components/lacrosse_view/translations/no.json +++ b/homeassistant/components/lacrosse_view/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/lametric/__init__.py b/homeassistant/components/lametric/__init__.py index 8b7c8b2dca2ceaaba758ab84b31f04d4303fcee4..5fd531234b89c83a47374e66c0619f34343eb014 100644 --- a/homeassistant/components/lametric/__init__.py +++ b/homeassistant/components/lametric/__init__.py @@ -12,6 +12,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS from .coordinator import LaMetricDataUpdateCoordinator +from .services import async_setup_services CONFIG_SCHEMA = vol.Schema( vol.All( @@ -31,6 +32,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LaMetric integration.""" + async_setup_services(hass) hass.data[DOMAIN] = {"hass_config": config} if DOMAIN in config: async_create_issue( @@ -51,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = LaMetricDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Set up notify platform, no entry support for notify component yet, diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 4d8c75f0ab0e0f66e392f43e30a120ee98a699dc..f6c2b03b02cbcaf0fa771b6e05fc9ed8f78a2fbd 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -16,6 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import LaMetricDataUpdateCoordinator from .entity import LaMetricEntity +from .helpers import lametric_exception_handler @dataclass @@ -47,6 +48,20 @@ BUTTONS = [ entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_previous(), ), + LaMetricButtonEntityDescription( + key="dismiss_current", + name="Dismiss current notification", + icon="mdi:bell-cancel", + entity_category=EntityCategory.CONFIG, + press_fn=lambda api: api.dismiss_current_notification(), + ), + LaMetricButtonEntityDescription( + key="dismiss_all", + name="Dismiss all notifications", + icon="mdi:bell-cancel", + entity_category=EntityCategory.CONFIG, + press_fn=lambda api: api.dismiss_all_notifications(), + ), ] @@ -81,6 +96,7 @@ class LaMetricButtonEntity(LaMetricEntity, ButtonEntity): self.entity_description = description self._attr_unique_id = f"{coordinator.data.serial_number}-{description.key}" + @lametric_exception_handler async def async_press(self) -> None: """Send out a command to LaMetric.""" await self.entity_description.press_fn(self.coordinator.lametric) diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py index a317d8354133f22884558a4a2a6f759b374819e6..7496fc51a4ed1690fb00cba68cc0572dcdc9e842 100644 --- a/homeassistant/components/lametric/config_flow.py +++ b/homeassistant/components/lametric/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the LaMetric integration.""" from __future__ import annotations +from collections.abc import Mapping from ipaddress import ip_address import logging from typing import Any @@ -27,6 +28,7 @@ from homeassistant.components.ssdp import ( ATTR_UPNP_SERIAL, SsdpServiceInfo, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_DEVICE, CONF_HOST, CONF_MAC from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -56,6 +58,7 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): discovered_host: str discovered_serial: str discovered: bool = False + reauth_entry: ConfigEntry | None = None @property def logger(self) -> logging.Logger: @@ -103,6 +106,13 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): self.discovered_serial = serial return await self.async_step_choice_enter_manual_or_fetch_cloud() + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with LaMetric.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_choice_enter_manual_or_fetch_cloud() + async def async_step_choice_enter_manual_or_fetch_cloud( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -120,6 +130,8 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): if user_input is not None: if self.discovered: host = self.discovered_host + elif self.reauth_entry: + host = self.reauth_entry.data[CONF_HOST] else: host = user_input[CONF_HOST] @@ -142,7 +154,7 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): TextSelectorConfig(type=TextSelectorType.PASSWORD) ) } - if not self.discovered: + if not self.discovered and not self.reauth_entry: schema = {vol.Required(CONF_HOST): TextSelector()} | schema return self.async_show_form( @@ -173,6 +185,10 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle device selection from devices offered by the cloud.""" if self.discovered: user_input = {CONF_DEVICE: self.discovered_serial} + elif self.reauth_entry: + if self.reauth_entry.unique_id not in self.devices: + return self.async_abort(reason="reauth_device_not_found") + user_input = {CONF_DEVICE: self.reauth_entry.unique_id} elif len(self.devices) == 1: user_input = {CONF_DEVICE: list(self.devices.values())[0].serial_number} @@ -223,10 +239,11 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): device = await lametric.device() - await self.async_set_unique_id(device.serial_number) - self._abort_if_unique_id_configured( - updates={CONF_HOST: lametric.host, CONF_API_KEY: lametric.api_key} - ) + if not self.reauth_entry: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_configured( + updates={CONF_HOST: lametric.host, CONF_API_KEY: lametric.api_key} + ) await lametric.notify( notification=Notification( @@ -240,6 +257,20 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): ) ) + if self.reauth_entry: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={ + **self.reauth_entry.data, + CONF_HOST: lametric.host, + CONF_API_KEY: lametric.api_key, + }, + ) + 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=device.name, data={ diff --git a/homeassistant/components/lametric/const.py b/homeassistant/components/lametric/const.py index da84450e784e856bcfcd892b82ebdc5d032952a2..4f9472b24f46362855c19be3cc9ef66eda99fbca 100644 --- a/homeassistant/components/lametric/const.py +++ b/homeassistant/components/lametric/const.py @@ -7,13 +7,24 @@ from typing import Final from homeassistant.const import Platform DOMAIN: Final = "lametric" -PLATFORMS = [Platform.BUTTON, Platform.NUMBER] +PLATFORMS = [ + Platform.BUTTON, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=30) CONF_CYCLES: Final = "cycles" +CONF_DATA: Final = "data" CONF_ICON_TYPE: Final = "icon_type" CONF_LIFETIME: Final = "lifetime" +CONF_MESSAGE: Final = "message" CONF_PRIORITY: Final = "priority" CONF_SOUND: Final = "sound" + +SERVICE_MESSAGE: Final = "message" +SERVICE_CHART: Final = "chart" diff --git a/homeassistant/components/lametric/coordinator.py b/homeassistant/components/lametric/coordinator.py index 0a5e99e5668777aeed68b06ac33f4677d234cdb7..88f34adf45cb4c02328d80f107842b03dc617f80 100644 --- a/homeassistant/components/lametric/coordinator.py +++ b/homeassistant/components/lametric/coordinator.py @@ -1,11 +1,12 @@ """DataUpdateCoordinator for the LaMatric integration.""" from __future__ import annotations -from demetriek import Device, LaMetricDevice, LaMetricError +from demetriek import Device, LaMetricAuthenticationError, LaMetricDevice, LaMetricError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -32,6 +33,8 @@ class LaMetricDataUpdateCoordinator(DataUpdateCoordinator[Device]): """Fetch device information of the LaMetric device.""" try: return await self.lametric.device() + except LaMetricAuthenticationError as err: + raise ConfigEntryAuthFailed from err except LaMetricError as ex: raise UpdateFailed( "Could not fetch device information from LaMetric device" diff --git a/homeassistant/components/lametric/diagnostics.py b/homeassistant/components/lametric/diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..256f5f06e915d974aa0fc22b80361d70e0eb6259 --- /dev/null +++ b/homeassistant/components/lametric/diagnostics.py @@ -0,0 +1,29 @@ +"""Diagnostics support for LaMetric.""" +from __future__ import annotations + +import json +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LaMetricDataUpdateCoordinator + +TO_REDACT = { + "device_id", + "name", + "serial_number", + "ssid", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + # Round-trip via JSON to trigger serialization + data = json.loads(coordinator.data.json()) + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/lametric/helpers.py b/homeassistant/components/lametric/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..6ca3157be0ce0193dcccc63a196c1c5213940de0 --- /dev/null +++ b/homeassistant/components/lametric/helpers.py @@ -0,0 +1,74 @@ +"""Helpers for LaMetric.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any, TypeVar + +from demetriek import LaMetricConnectionError, LaMetricError +from typing_extensions import Concatenate, ParamSpec + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN +from .coordinator import LaMetricDataUpdateCoordinator +from .entity import LaMetricEntity + +_LaMetricEntityT = TypeVar("_LaMetricEntityT", bound=LaMetricEntity) +_P = ParamSpec("_P") + + +def lametric_exception_handler( + func: Callable[Concatenate[_LaMetricEntityT, _P], Coroutine[Any, Any, Any]] +) -> Callable[Concatenate[_LaMetricEntityT, _P], Coroutine[Any, Any, None]]: + """Decorate LaMetric calls to handle LaMetric exceptions. + + A decorator that wraps the passed in function, catches LaMetric errors, + and handles the availability of the device in the data coordinator. + """ + + async def handler( + self: _LaMetricEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + try: + await func(self, *args, **kwargs) + self.coordinator.async_update_listeners() + + except LaMetricConnectionError as error: + self.coordinator.last_update_success = False + self.coordinator.async_update_listeners() + raise HomeAssistantError( + "Error communicating with the LaMetric device" + ) from error + + except LaMetricError as error: + raise HomeAssistantError( + "Invalid response from the LaMetric device" + ) from error + + return handler + + +@callback +def async_get_coordinator_by_device_id( + hass: HomeAssistant, device_id: str +) -> LaMetricDataUpdateCoordinator: + """Get the LaMetric coordinator for this device ID.""" + device_registry = dr.async_get(hass) + + if (device_entry := device_registry.async_get(device_id)) is None: + raise ValueError(f"Unknown LaMetric device ID: {device_id}") + + for entry_id in device_entry.config_entries: + if ( + (entry := hass.config_entries.async_get_entry(entry_id)) + and entry.domain == DOMAIN + and entry.entry_id in hass.data[DOMAIN] + ): + coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + return coordinator + + raise ValueError(f"No coordinator for device ID: {device_id}") diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json index cddf28e5487a2585a1791c13eab9fbb0ee2bfba7..26963e136edfbb58550c11534d33357a68d9a127 100644 --- a/homeassistant/components/lametric/manifest.json +++ b/homeassistant/components/lametric/manifest.json @@ -2,8 +2,8 @@ "domain": "lametric", "name": "LaMetric", "documentation": "https://www.home-assistant.io/integrations/lametric", - "requirements": ["demetriek==0.2.4"], - "codeowners": ["@robbiet480", "@frenck"], + "requirements": ["demetriek==0.4.0"], + "codeowners": ["@robbiet480", "@frenck", "@bachya"], "iot_class": "local_polling", "dependencies": ["application_credentials"], "loggers": ["demetriek"], @@ -13,5 +13,7 @@ "deviceType": "urn:schemas-upnp-org:device:LaMetric:1" } ], - "dhcp": [{ "registered_devices": true }] + "dhcp": [{ "registered_devices": true }], + "quality_scale": "platinum", + "integration_type": "device" } diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index c788eb3255e33c2e6ba53b7c2e45d11f930c9a13..c82fded83c8b2ea658cf46aaf620489b7887fb63 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -9,6 +9,7 @@ from demetriek import Device, LaMetricDevice from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -16,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import LaMetricDataUpdateCoordinator from .entity import LaMetricEntity +from .helpers import lametric_exception_handler @dataclass @@ -34,6 +36,18 @@ class LaMetricNumberEntityDescription( NUMBERS = [ + LaMetricNumberEntityDescription( + key="brightness", + name="Brightness", + icon="mdi:brightness-6", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.display.brightness, + set_value_fn=lambda device, bri: device.display(brightness=int(bri)), + ), LaMetricNumberEntityDescription( key="volume", name="Volume", @@ -84,6 +98,7 @@ class LaMetricNumberEntity(LaMetricEntity, NumberEntity): """Return the number value.""" return self.entity_description.value_fn(self.coordinator.data) + @lametric_exception_handler async def async_set_native_value(self, value: float) -> None: """Change to new number value.""" await self.entity_description.set_value_fn(self.coordinator.lametric, value) diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py new file mode 100644 index 0000000000000000000000000000000000000000..e15e33facfcb2f87a86bf18b1f48ebe2138c3a46 --- /dev/null +++ b/homeassistant/components/lametric/select.py @@ -0,0 +1,91 @@ +"""Support for LaMetric selects.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from demetriek import BrightnessMode, Device, LaMetricDevice + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +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 +from .coordinator import LaMetricDataUpdateCoordinator +from .entity import LaMetricEntity +from .helpers import lametric_exception_handler + + +@dataclass +class LaMetricEntityDescriptionMixin: + """Mixin values for LaMetric entities.""" + + current_fn: Callable[[Device], str] + select_fn: Callable[[LaMetricDevice, str], Awaitable[Any]] + + +@dataclass +class LaMetricSelectEntityDescription( + SelectEntityDescription, LaMetricEntityDescriptionMixin +): + """Class describing LaMetric select entities.""" + + +SELECTS = [ + LaMetricSelectEntityDescription( + key="brightness_mode", + name="Brightness mode", + icon="mdi:brightness-auto", + entity_category=EntityCategory.CONFIG, + device_class="lametric__brightness_mode", + options=["auto", "manual"], + current_fn=lambda device: device.display.brightness_mode.value, + select_fn=lambda api, opt: api.display(brightness_mode=BrightnessMode(opt)), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up LaMetric select based on a config entry.""" + coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + LaMetricSelectEntity( + coordinator=coordinator, + description=description, + ) + for description in SELECTS + ) + + +class LaMetricSelectEntity(LaMetricEntity, SelectEntity): + """Representation of a LaMetric select.""" + + entity_description: LaMetricSelectEntityDescription + + def __init__( + self, + coordinator: LaMetricDataUpdateCoordinator, + description: LaMetricSelectEntityDescription, + ) -> None: + """Initiate LaMetric Select.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.serial_number}-{description.key}" + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return self.entity_description.current_fn(self.coordinator.data) + + @lametric_exception_handler + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.select_fn(self.coordinator.lametric, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..b9ff430ab2bdb7961c778b34c80c54f3e298d989 --- /dev/null +++ b/homeassistant/components/lametric/sensor.py @@ -0,0 +1,87 @@ +"""Support for LaMetric sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from demetriek import Device + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import LaMetricDataUpdateCoordinator +from .entity import LaMetricEntity + + +@dataclass +class LaMetricEntityDescriptionMixin: + """Mixin values for LaMetric entities.""" + + value_fn: Callable[[Device], int | None] + + +@dataclass +class LaMetricSensorEntityDescription( + SensorEntityDescription, LaMetricEntityDescriptionMixin +): + """Class describing LaMetric sensor entities.""" + + +SENSORS = [ + LaMetricSensorEntityDescription( + key="rssi", + name="Wi-Fi signal", + icon="mdi:wifi", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.wifi.rssi, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up LaMetric sensor based on a config entry.""" + coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + LaMetricSensorEntity( + coordinator=coordinator, + description=description, + ) + for description in SENSORS + ) + + +class LaMetricSensorEntity(LaMetricEntity, SensorEntity): + """Representation of a LaMetric sensor.""" + + entity_description: LaMetricSensorEntityDescription + + def __init__( + self, + coordinator: LaMetricDataUpdateCoordinator, + description: LaMetricSensorEntityDescription, + ) -> None: + """Initiate LaMetric sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.serial_number}-{description.key}" + + @property + def native_value(self) -> int | None: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/lametric/services.py b/homeassistant/components/lametric/services.py new file mode 100644 index 0000000000000000000000000000000000000000..2cbdfff6fd8fe882e69a1fe45b098078db88965d --- /dev/null +++ b/homeassistant/components/lametric/services.py @@ -0,0 +1,136 @@ +"""Support for LaMetric time services.""" +from __future__ import annotations + +from collections.abc import Sequence + +from demetriek import ( + AlarmSound, + Chart, + LaMetricError, + Model, + Notification, + NotificationIconType, + NotificationPriority, + NotificationSound, + Simple, + Sound, +) +import voluptuous as vol + +from homeassistant.const import CONF_DEVICE_ID, CONF_ICON +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +from .const import ( + CONF_CYCLES, + CONF_DATA, + CONF_ICON_TYPE, + CONF_MESSAGE, + CONF_PRIORITY, + CONF_SOUND, + DOMAIN, + SERVICE_CHART, + SERVICE_MESSAGE, +) +from .coordinator import LaMetricDataUpdateCoordinator +from .helpers import async_get_coordinator_by_device_id + +SERVICE_BASE_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Optional(CONF_CYCLES, default=1): cv.positive_int, + vol.Optional(CONF_ICON_TYPE, default=NotificationIconType.NONE): vol.Coerce( + NotificationIconType + ), + vol.Optional(CONF_PRIORITY, default=NotificationPriority.INFO): vol.Coerce( + NotificationPriority + ), + vol.Optional(CONF_SOUND): vol.Any( + vol.Coerce(AlarmSound), vol.Coerce(NotificationSound) + ), + } +) + +SERVICE_MESSAGE_SCHEMA = SERVICE_BASE_SCHEMA.extend( + { + vol.Required(CONF_MESSAGE): cv.string, + vol.Optional(CONF_ICON): cv.string, + } +) + +SERVICE_CHART_SCHEMA = SERVICE_BASE_SCHEMA.extend( + { + vol.Required(CONF_DATA): vol.All(cv.ensure_list, [vol.Coerce(int)]), + } +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for the LaMetric integration.""" + + async def _async_service_chart(call: ServiceCall) -> None: + """Send a chart to a LaMetric device.""" + coordinator = async_get_coordinator_by_device_id( + hass, call.data[CONF_DEVICE_ID] + ) + await async_send_notification( + coordinator, call, [Chart(data=call.data[CONF_DATA])] + ) + + async def _async_service_message(call: ServiceCall) -> None: + """Send a message to a LaMetric device.""" + coordinator = async_get_coordinator_by_device_id( + hass, call.data[CONF_DEVICE_ID] + ) + await async_send_notification( + coordinator, + call, + [ + Simple( + icon=call.data.get(CONF_ICON), + text=call.data[CONF_MESSAGE], + ) + ], + ) + + hass.services.async_register( + DOMAIN, + SERVICE_CHART, + _async_service_chart, + schema=SERVICE_CHART_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_MESSAGE, + _async_service_message, + schema=SERVICE_MESSAGE_SCHEMA, + ) + + +async def async_send_notification( + coordinator: LaMetricDataUpdateCoordinator, + call: ServiceCall, + frames: Sequence[Chart | Simple], +) -> None: + """Send a notification to an LaMetric device.""" + sound = None + if CONF_SOUND in call.data: + sound = Sound(id=call.data[CONF_SOUND], category=None) + + notification = Notification( + icon_type=NotificationIconType(call.data[CONF_ICON_TYPE]), + priority=NotificationPriority(call.data.get(CONF_PRIORITY)), + model=Model( + frames=frames, + cycles=call.data[CONF_CYCLES], + sound=sound, + ), + ) + + try: + await coordinator.lametric.notify(notification=notification) + except LaMetricError as ex: + raise HomeAssistantError("Could not send LaMetric notification") from ex diff --git a/homeassistant/components/lametric/services.yaml b/homeassistant/components/lametric/services.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5e8db5f7da4efc324d0f216abc5658d216cb7b23 --- /dev/null +++ b/homeassistant/components/lametric/services.yaml @@ -0,0 +1,191 @@ +chart: + name: Display a chart + description: Display a chart on a LaMetric device. + fields: + device_id: &device_id + name: Device + description: The LaMetric device to display the chart on. + required: true + selector: + device: + integration: lametric + data: + name: Data + description: The list of data points in the chart + required: true + example: "[1,2,3,4,5,4,3,2,1]" + selector: + object: + sound: &sound + name: Sound + description: The notification sound to play. + required: false + selector: + select: + options: + - label: "Alarm 1" + value: "alarm1" + - label: "Alarm 2" + value: "alarm2" + - label: "Alarm 3" + value: "alarm3" + - label: "Alarm 4" + value: "alarm4" + - label: "Alarm 5" + value: "alarm5" + - label: "Alarm 6" + value: "alarm6" + - label: "Alarm 7" + value: "alarm7" + - label: "Alarm 8" + value: "alarm8" + - label: "Alarm 9" + value: "alarm9" + - label: "Alarm 10" + value: "alarm10" + - label: "Alarm 11" + value: "alarm11" + - label: "Alarm 12" + value: "alarm12" + - label: "Alarm 13" + value: "alarm13" + - label: "Bicycle" + value: "bicycle" + - label: "Car" + value: "car" + - label: "Cash" + value: "cash" + - label: "Cat" + value: "cat" + - label: "Dog 1" + value: "dog" + - label: "Dog 2" + value: "dog2" + - label: "Energy" + value: "energy" + - label: "Knock knock" + value: "knock-knock" + - label: "Letter email" + value: "letter_email" + - label: "Lose 1" + value: "lose1" + - label: "Lose 2" + value: "lose2" + - label: "Negative 1" + value: "negative1" + - label: "Negative 2" + value: "negative2" + - label: "Negative 3" + value: "negative3" + - label: "Negative 4" + value: "negative4" + - label: "Negative 5" + value: "negative5" + - label: "Notification 1" + value: "notification" + - label: "Notification 2" + value: "notification2" + - label: "Notification 3" + value: "notification3" + - label: "Notification 4" + value: "notification4" + - label: "Open door" + value: "open_door" + - label: "Positive 1" + value: "positive1" + - label: "Positive 2" + value: "positive2" + - label: "Positive 3" + value: "positive3" + - label: "Positive 4" + value: "positive4" + - label: "Positive 5" + value: "positive5" + - label: "Positive 6" + value: "positive6" + - label: "Static" + value: "static" + - label: "Thunder" + value: "thunder" + - label: "Water 1" + value: "water1" + - label: "Water 2" + value: "water2" + - label: "Win 1" + value: "win" + - label: "Win 2" + value: "win2" + - label: "Wind" + value: "wind" + - label: "Wind short" + value: "wind_short" + cycles: &cycles + name: Cycles + description: >- + The number of times to display the message. When set to 0, the message + will be displayed until dismissed. + required: false + default: 1 + selector: + number: + min: 0 + max: 10 + mode: slider + icon_type: &icon_type + name: Icon type + description: >- + The type of icon to display, indicating the nature of the notification. + required: false + default: "none" + selector: + select: + mode: dropdown + options: + - label: "None" + value: "none" + - label: "Info" + value: "info" + - label: "Alert" + value: "alert" + priority: &priority + name: Priority + description: >- + The priority of the notification. When the device is running in + screensaver or kiosk mode, only critical priority notifications + will be accepted. + required: false + default: "info" + selector: + select: + mode: dropdown + options: + - label: "Info" + value: "info" + - label: "Warning" + value: "warning" + - label: "Critical" + value: "critical" + +message: + name: Display a message + description: Display a message with an optional icon on a LaMetric device. + fields: + device_id: *device_id + message: + name: Message + description: The message to display. + required: true + selector: + text: + icon: + name: Icon + description: >- + The ID number of the icon or animation to display. List of all icons + and their IDs can be found at: https://developer.lametric.com/icons + required: false + selector: + text: + sound: *sound + cycles: *cycles + icon_type: *icon_type + priority: *priority diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 433f70df18d6d6271b02796b406f92f1bba40959..768f8e2b740516cb416f073b2f2d1a183727d6f9 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -39,6 +39,8 @@ "missing_configuration": "The LaMetric integration is not configured. Please follow the documentation.", "no_devices": "The authorized user has no LaMetric devices", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "reauth_device_not_found": "The device you are trying to re-authenticate is not found in this LaMetric account", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/homeassistant/components/lametric/strings.select.json b/homeassistant/components/lametric/strings.select.json new file mode 100644 index 0000000000000000000000000000000000000000..1d2ce0a2ce79dd757761523e2a8d9e7825428c8a --- /dev/null +++ b/homeassistant/components/lametric/strings.select.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Automatic", + "manual": "Manual" + } + } +} diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py new file mode 100644 index 0000000000000000000000000000000000000000..c9f7ce047aa25b57a89631c480f33c6dd726fc77 --- /dev/null +++ b/homeassistant/components/lametric/switch.py @@ -0,0 +1,105 @@ +"""Support for LaMetric switches.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from demetriek import Device, LaMetricDevice + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +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 +from .coordinator import LaMetricDataUpdateCoordinator +from .entity import LaMetricEntity +from .helpers import lametric_exception_handler + + +@dataclass +class LaMetricEntityDescriptionMixin: + """Mixin values for LaMetric entities.""" + + is_on_fn: Callable[[Device], bool] + set_fn: Callable[[LaMetricDevice, bool], Awaitable[Any]] + + +@dataclass +class LaMetricSwitchEntityDescription( + SwitchEntityDescription, LaMetricEntityDescriptionMixin +): + """Class describing LaMetric switch entities.""" + + available_fn: Callable[[Device], bool] = lambda device: True + + +SWITCHES = [ + LaMetricSwitchEntityDescription( + key="bluetooth", + name="Bluetooth", + icon="mdi:bluetooth", + entity_category=EntityCategory.CONFIG, + available_fn=lambda device: device.bluetooth.available, + is_on_fn=lambda device: device.bluetooth.active, + set_fn=lambda api, active: api.bluetooth(active=active), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up LaMetric switch based on a config entry.""" + coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + LaMetricSwitchEntity( + coordinator=coordinator, + description=description, + ) + for description in SWITCHES + ) + + +class LaMetricSwitchEntity(LaMetricEntity, SwitchEntity): + """Representation of a LaMetric switch.""" + + entity_description: LaMetricSwitchEntityDescription + + def __init__( + self, + coordinator: LaMetricDataUpdateCoordinator, + description: LaMetricSwitchEntityDescription, + ) -> None: + """Initiate LaMetric Switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.serial_number}-{description.key}" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.entity_description.available_fn( + self.coordinator.data + ) + + @property + def is_on(self) -> bool: + """Return state of the switch.""" + return self.entity_description.is_on_fn(self.coordinator.data) + + @lametric_exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.entity_description.set_fn(self.coordinator.lametric, True) + await self.coordinator.async_request_refresh() + + @lametric_exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.entity_description.set_fn(self.coordinator.lametric, False) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/lametric/translations/bg.json b/homeassistant/components/lametric/translations/bg.json index 28104788cbd08f9a1ec358d88d7bd95af7f1d1ed..6f85c1ddaf5cd668000e3e44329f324debf4e74f 100644 --- a/homeassistant/components/lametric/translations/bg.json +++ b/homeassistant/components/lametric/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", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u0442\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u0430", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "error": { diff --git a/homeassistant/components/lametric/translations/ca.json b/homeassistant/components/lametric/translations/ca.json index 0c7a30099e519f1c1096a70ca4b8de628577285d..b5f3d609052db3f1964fbf8317649bd458168739 100644 --- a/homeassistant/components/lametric/translations/ca.json +++ b/homeassistant/components/lametric/translations/ca.json @@ -8,6 +8,8 @@ "missing_configuration": "La integraci\u00f3 LaMetric no est\u00e0 configurada. Consulta la documentaci\u00f3.", "no_devices": "L'usuari autoritzat no t\u00e9 cap dispositiu LaMetric", "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})", + "reauth_device_not_found": "El dispositiu que est\u00e0s intentant tornar a autenticar no es troba en aquest compte de LaMetric", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown": "Error inesperat" }, "error": { diff --git a/homeassistant/components/lametric/translations/de.json b/homeassistant/components/lametric/translations/de.json index 8f34796318281647c7a2c62a8bd9629319302aac..15d98a45dbb7424bfebbb794474b9e950c2801df 100644 --- a/homeassistant/components/lametric/translations/de.json +++ b/homeassistant/components/lametric/translations/de.json @@ -8,6 +8,8 @@ "missing_configuration": "Die LaMetric-Integration ist nicht konfiguriert. Bitte folge der Dokumentation.", "no_devices": "Der autorisierte Benutzer hat keine LaMetric Ger\u00e4te", "no_url_available": "Keine URL verf\u00fcgbar. Informationen zu diesem Fehler findest du [im Hilfebereich]({docs_url}).", + "reauth_device_not_found": "Das Ger\u00e4t, das du erneut authentifizieren m\u00f6chtest, wird in diesem LaMetric-Konto nicht gefunden", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", "unknown": "Unerwarteter Fehler" }, "error": { diff --git a/homeassistant/components/lametric/translations/en.json b/homeassistant/components/lametric/translations/en.json index 52e483ec1f0373228640bbc8eb1fd648218412cf..c36b490fcd2ec77ad329d29a21a0e14d4c0aa82b 100644 --- a/homeassistant/components/lametric/translations/en.json +++ b/homeassistant/components/lametric/translations/en.json @@ -8,6 +8,8 @@ "missing_configuration": "The LaMetric integration is not configured. Please follow the documentation.", "no_devices": "The authorized user has no LaMetric devices", "no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", + "reauth_device_not_found": "The device you are trying to re-authenticate is not found in this LaMetric account", + "reauth_successful": "Re-authentication was successful", "unknown": "Unexpected error" }, "error": { diff --git a/homeassistant/components/lametric/translations/es.json b/homeassistant/components/lametric/translations/es.json index 7cc6fc36cf3530519e466294dec732ff98514440..47fccfea279fddf002c7ff586b1d60becf776f97 100644 --- a/homeassistant/components/lametric/translations/es.json +++ b/homeassistant/components/lametric/translations/es.json @@ -8,6 +8,8 @@ "missing_configuration": "La integraci\u00f3n de LaMetric no est\u00e1 configurada. Por favor, sigue la documentaci\u00f3n.", "no_devices": "El usuario autorizado no tiene dispositivos LaMetric", "no_url_available": "No hay URL disponible. Para obtener informaci\u00f3n sobre este error, [revisa la secci\u00f3n de ayuda]({docs_url})", + "reauth_device_not_found": "El dispositivo que est\u00e1s tratando de volver a autenticar no se encuentra en esta cuenta de LaMetric", + "reauth_successful": "La autenticaci\u00f3n se volvi\u00f3 a realizar correctamente", "unknown": "Error inesperado" }, "error": { diff --git a/homeassistant/components/lametric/translations/et.json b/homeassistant/components/lametric/translations/et.json index f2ec397481d22ad485ba809e129b01bf4bff6fbb..008b4e875de6415b3ca8a741716c915e7744693b 100644 --- a/homeassistant/components/lametric/translations/et.json +++ b/homeassistant/components/lametric/translations/et.json @@ -8,6 +8,8 @@ "missing_configuration": "LaMetricu integratsioon pole konfigureeritud. Palun j\u00e4rgige dokumentatsiooni.", "no_devices": "Volitatud kasutajal pole LaMetricu seadmeid", "no_url_available": "URL pole saadaval. Teavet selle veateate kohta saab [check the help section]({docs_url})", + "reauth_device_not_found": "Seadet, mida proovid uuesti autentida, ei leita sellelt LaMetricu kontolt", + "reauth_successful": "Taastuvastamine \u00f5nnestus", "unknown": "Ootamatu t\u00f5rge" }, "error": { diff --git a/homeassistant/components/lametric/translations/fr.json b/homeassistant/components/lametric/translations/fr.json index fd0125f68c9f9d150e9953e923971fab7f3584ed..3bd9204ad5600b78da8bf9951fddb155c8f35db8 100644 --- a/homeassistant/components/lametric/translations/fr.json +++ b/homeassistant/components/lametric/translations/fr.json @@ -8,6 +8,7 @@ "missing_configuration": "L'int\u00e9gration LaMetric n'est pas configur\u00e9e\u00a0; veuillez suivre la documentation.", "no_devices": "L'utilisateur autoris\u00e9 ne poss\u00e8de aucun appareil LaMetric", "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide]({docs_url})", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "unknown": "Erreur inattendue" }, "error": { diff --git a/homeassistant/components/lametric/translations/he.json b/homeassistant/components/lametric/translations/he.json index 53f74430ae16019030ddb892ed71f77ac27f601c..97c060f6062c7dfcc66ff6174f588eb0053133ca 100644 --- a/homeassistant/components/lametric/translations/he.json +++ b/homeassistant/components/lametric/translations/he.json @@ -1,6 +1,14 @@ { "config": { "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "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.", + "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})", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" } } diff --git a/homeassistant/components/lametric/translations/hu.json b/homeassistant/components/lametric/translations/hu.json index 0e326d4b4e884937fdc1b1bbc365cabc8acc3d0f..748f4bb59beb6a4725d08df41d00527da86368fb 100644 --- a/homeassistant/components/lametric/translations/hu.json +++ b/homeassistant/components/lametric/translations/hu.json @@ -8,6 +8,8 @@ "missing_configuration": "A LaMetric integr\u00e1ci\u00f3 nincs konfigur\u00e1lva. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3t.", "no_devices": "A jogosult felhaszn\u00e1l\u00f3 nem rendelkezik LaMetric-eszk\u00f6z\u00f6kkel", "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.", + "reauth_device_not_found": "Az \u00fajb\u00f3l hiteles\u00edteni k\u00edv\u00e1nt eszk\u00f6z nem tal\u00e1lhat\u00f3 ebben a LaMetric-fi\u00f3kban", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { diff --git a/homeassistant/components/lametric/translations/id.json b/homeassistant/components/lametric/translations/id.json index e668efa040302211bbc2fed8bdb45ae08c3b757d..e6010f08e3d654dcc2f19799bd2eb72616472812 100644 --- a/homeassistant/components/lametric/translations/id.json +++ b/homeassistant/components/lametric/translations/id.json @@ -8,6 +8,8 @@ "missing_configuration": "Integrasi LaMetric tidak dikonfigurasi. Silakan ikuti dokumentasi.", "no_devices": "Pengguna yang diotorisasi tidak memiliki perangkat LaMetric", "no_url_available": "Tidak ada URL yang tersedia. Untuk informasi tentang kesalahan ini, [lihat bagian bantuan]({docs_url})", + "reauth_device_not_found": "Perangkat yang Anda coba autentikasi ulang tidak ditemukan di akun LaMetric ini", + "reauth_successful": "Autentikasi ulang berhasil", "unknown": "Kesalahan yang tidak diharapkan" }, "error": { @@ -45,7 +47,7 @@ "issues": { "manual_migration": { "description": "Integrasi LaMetric telah dimodernisasi: kini integrasinya dikonfigurasi dan disiapkan melalui antarmuka pengguna dan komunikasi menjadi lokal.\n\nSayangnya, tidak ada jalur migrasi otomatis yang mungkin dan oleh sebab itu Anda harus mengatur ulang integrasi LaMetric dengan Home Assistant. Baca dokumentasi integrasi LaMetric Home Assistant tentang cara persiapannya.\n\nHapus konfigurasi YAML LaMetric yang lama dari file configuration.yaml Anda dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Migrasi manual diperlukan untuk LaMetric" + "title": "Migrasi manual diperlukan untuk Integrasi LaMetric" } } } \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/it.json b/homeassistant/components/lametric/translations/it.json index c349602270157f5fafb7e46ea0ac8b1e5c2f3350..c6b8f6c84dfa67488f0018ad3970fe9134ab873f 100644 --- a/homeassistant/components/lametric/translations/it.json +++ b/homeassistant/components/lametric/translations/it.json @@ -8,6 +8,8 @@ "missing_configuration": "L'integrazione LaMetric non \u00e8 configurata. Segui la documentazione.", "no_devices": "L'utente autorizzato non dispone di dispositivi LaMetric", "no_url_available": "Nessun URL disponibile. Per informazioni su questo errore, [controlla la sezione della guida]({docs_url})", + "reauth_device_not_found": "Il dispositivo che stai tentando di riautenticare non \u00e8 stato trovato in questo account LaMetric", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "unknown": "Errore imprevisto" }, "error": { diff --git a/homeassistant/components/lametric/translations/nb.json b/homeassistant/components/lametric/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/lametric/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/nl.json b/homeassistant/components/lametric/translations/nl.json index f88338f44107276ba9f7c5c88b3dd999efcbfec7..addce23b5c96b293a8171631aaea51b727707f1c 100644 --- a/homeassistant/components/lametric/translations/nl.json +++ b/homeassistant/components/lametric/translations/nl.json @@ -4,6 +4,7 @@ "already_configured": "Apparaat is al geconfigureerd", "authorize_url_timeout": "Time-out bij het genereren van autorisatie-URL.", "no_url_available": "Geen URL beschikbaar. Voor informatie over deze fout, [raadpleeg de documentatie]({docs_url})", + "reauth_successful": "Herauthenticatie geslaagd", "unknown": "Onverwachte fout" }, "error": { @@ -13,7 +14,8 @@ "step": { "choice_enter_manual_or_fetch_cloud": { "menu_options": { - "manual_entry": "Handmatig invoeren" + "manual_entry": "Handmatig invoeren", + "pick_implementation": "Importeren van LaMetric.com (aanbevolen)" } }, "manual_entry": { @@ -24,6 +26,11 @@ }, "pick_implementation": { "title": "Kies een authenticatie methode" + }, + "user_cloud_select_device": { + "data": { + "device": "Selecteer het LaMetric-apparaat dat u wilt toevoegen" + } } } } diff --git a/homeassistant/components/lametric/translations/no.json b/homeassistant/components/lametric/translations/no.json index 4984e190241dfd8d7ea3b955f5428d70d90db776..e1c1f33169297692ae0d83535749fa4f277e4709 100644 --- a/homeassistant/components/lametric/translations/no.json +++ b/homeassistant/components/lametric/translations/no.json @@ -8,6 +8,8 @@ "missing_configuration": "LaMetric-integrasjonen er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen.", "no_devices": "Den autoriserte brukeren har ingen LaMetric-enheter", "no_url_available": "Ingen URL tilgjengelig. For informasjon om denne feilen, [sjekk hjelpseksjonen]({docs_url})", + "reauth_device_not_found": "Enheten du pr\u00f8ver \u00e5 re-autentisere ble ikke funnet i denne LaMetric-kontoen", + "reauth_successful": "Re-autentisering var vellykket", "unknown": "Uventet feil" }, "error": { diff --git a/homeassistant/components/lametric/translations/pl.json b/homeassistant/components/lametric/translations/pl.json index 8ba3215cbe2b845ecf56e617880561c816ec73c1..879ab701be7910f8368c02311ee469e67a3ca5d0 100644 --- a/homeassistant/components/lametric/translations/pl.json +++ b/homeassistant/components/lametric/translations/pl.json @@ -8,6 +8,8 @@ "missing_configuration": "Komponent nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105.", "no_devices": "Autoryzowany u\u017cytkownik nie posiada urz\u0105dze\u0144 LaMetric", "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})", + "reauth_device_not_found": "Urz\u0105dzenie, kt\u00f3re pr\u00f3bujesz ponownie uwierzytelni\u0107, nie znajduje si\u0119 na tym koncie LaMetric", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "error": { diff --git a/homeassistant/components/lametric/translations/pt-BR.json b/homeassistant/components/lametric/translations/pt-BR.json index b9834dbfa2f89c2789c82b0805aa14bf80221683..8c419582d9d07f97017dbd1a2b4c0e1f2ae8056e 100644 --- a/homeassistant/components/lametric/translations/pt-BR.json +++ b/homeassistant/components/lametric/translations/pt-BR.json @@ -8,6 +8,8 @@ "missing_configuration": "A integra\u00e7\u00e3o LaMetric n\u00e3o est\u00e1 configurada. Por favor, siga a documenta\u00e7\u00e3o.", "no_devices": "O usu\u00e1rio autorizado n\u00e3o possui dispositivos LaMetric", "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})", + "reauth_device_not_found": "O dispositivo que voc\u00ea est\u00e1 tentando autenticar novamente n\u00e3o foi encontrado nesta conta LaMetric", + "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida", "unknown": "Erro inesperado" }, "error": { diff --git a/homeassistant/components/lametric/translations/pt.json b/homeassistant/components/lametric/translations/pt.json index ca715b2e6e278b0fdb56b8cc9b2922a90fae46f5..74a501a1ca8c50b2d1393e7e503c66b978432272 100644 --- a/homeassistant/components/lametric/translations/pt.json +++ b/homeassistant/components/lametric/translations/pt.json @@ -4,7 +4,8 @@ "invalid_discovery_info": "Informa\u00e7\u00f5es de descoberta inv\u00e1lidas recebidas", "link_local_address": "Endere\u00e7os locais de link n\u00e3o s\u00e3o suportados", "missing_configuration": "A integra\u00e7\u00e3o LaMetric n\u00e3o est\u00e1 configurada. Por favor, siga a documenta\u00e7\u00e3o.", - "no_devices": "O usu\u00e1rio autorizado n\u00e3o possui dispositivos LaMetric" + "no_devices": "O usu\u00e1rio autorizado n\u00e3o possui dispositivos LaMetric", + "reauth_successful": "Reautentica\u00e7\u00e3o bem sucedida" }, "step": { "choice_enter_manual_or_fetch_cloud": { diff --git a/homeassistant/components/lametric/translations/ru.json b/homeassistant/components/lametric/translations/ru.json index cf9324b9abe337fa789dfe63746819c7f731a07c..df712e4dc65885102ab8755780c182dcc98e090b 100644 --- a/homeassistant/components/lametric/translations/ru.json +++ b/homeassistant/components/lametric/translations/ru.json @@ -8,6 +8,8 @@ "missing_configuration": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f LaMetric \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438.", "no_devices": "\u0423 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u043e\u0432\u0430\u043d\u043d\u043e\u0433\u043e \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0435\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 LaMetric.", "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.", + "reauth_device_not_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e, \u043a\u043e\u0442\u043e\u0440\u043e\u0435 \u0412\u044b \u043f\u044b\u0442\u0430\u0435\u0442\u0435\u0441\u044c \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u0446\u0438\u0440\u043e\u0432\u0430\u0442\u044c, \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e \u0432 \u044d\u0442\u043e\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 LaMetric.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { diff --git a/homeassistant/components/lametric/translations/select.bg.json b/homeassistant/components/lametric/translations/select.bg.json new file mode 100644 index 0000000000000000000000000000000000000000..94363e744b401f3b81c8854a943e2e53c7132db5 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.bg.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e", + "manual": "\u0420\u044a\u0447\u043do" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.ca.json b/homeassistant/components/lametric/translations/select.ca.json new file mode 100644 index 0000000000000000000000000000000000000000..045ad08acf4d507d21f35476e1276fa0a7487424 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.ca.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Autom\u00e0tic", + "manual": "Manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.de.json b/homeassistant/components/lametric/translations/select.de.json new file mode 100644 index 0000000000000000000000000000000000000000..1b1a5ab8ce6dc461c6d70b179b6c18e75c2e6bac --- /dev/null +++ b/homeassistant/components/lametric/translations/select.de.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Automatisch", + "manual": "Manuell" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.en.json b/homeassistant/components/lametric/translations/select.en.json new file mode 100644 index 0000000000000000000000000000000000000000..de1f7e5f642ff0c87e0625b824b6c212ba06b513 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.en.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Automatic", + "manual": "Manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.es.json b/homeassistant/components/lametric/translations/select.es.json new file mode 100644 index 0000000000000000000000000000000000000000..dcf5c796e00c41527157952151d284eb7f63c908 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.es.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Autom\u00e1tico", + "manual": "Manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.et.json b/homeassistant/components/lametric/translations/select.et.json new file mode 100644 index 0000000000000000000000000000000000000000..69c1bcd94f2f41b5692c95edb5192e51f6a84853 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.et.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Automaatne", + "manual": "K\u00e4sitsi" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.fr.json b/homeassistant/components/lametric/translations/select.fr.json new file mode 100644 index 0000000000000000000000000000000000000000..6502e00f7fe92ab6ac3ef987a6a76de75def9d39 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.fr.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Automatique", + "manual": "Manuel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.he.json b/homeassistant/components/lametric/translations/select.he.json new file mode 100644 index 0000000000000000000000000000000000000000..c8f20cb88736850c98cdd7e9e48b77d2e3f37640 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.he.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "\u05d0\u05d5\u05d8\u05d5\u05de\u05d8\u05d9", + "manual": "\u05d9\u05d3\u05e0\u05d9\u05ea" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.hu.json b/homeassistant/components/lametric/translations/select.hu.json new file mode 100644 index 0000000000000000000000000000000000000000..231888f3252386be0540ee8c2b0b9271a6ff90b8 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.hu.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Automatikus", + "manual": "Manu\u00e1lis" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.id.json b/homeassistant/components/lametric/translations/select.id.json new file mode 100644 index 0000000000000000000000000000000000000000..737f4ac624bcd4f0fbaad8f053fd6e21e7206c39 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.id.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Otomatis", + "manual": "Manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.it.json b/homeassistant/components/lametric/translations/select.it.json new file mode 100644 index 0000000000000000000000000000000000000000..cee7cb7796d205aeccbb55648b4e03d8d25ed66f --- /dev/null +++ b/homeassistant/components/lametric/translations/select.it.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Automatico", + "manual": "Manuale" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.ja.json b/homeassistant/components/lametric/translations/select.ja.json new file mode 100644 index 0000000000000000000000000000000000000000..b918cfa7a7a5fa1b9c142795397c2ddbd6e594d9 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.ja.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "\u81ea\u52d5", + "manual": "\u30de\u30cb\u30e5\u30a2\u30eb" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.nl.json b/homeassistant/components/lametric/translations/select.nl.json new file mode 100644 index 0000000000000000000000000000000000000000..7cbf1a89a90778ae4edb516d4f161d86c2b5216f --- /dev/null +++ b/homeassistant/components/lametric/translations/select.nl.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Automatisch", + "manual": "Handmatig" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.no.json b/homeassistant/components/lametric/translations/select.no.json new file mode 100644 index 0000000000000000000000000000000000000000..a23b382dc4939efb1786bf9c1b9829d9e4989d53 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.no.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Automatisk", + "manual": "Manuell" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.pl.json b/homeassistant/components/lametric/translations/select.pl.json new file mode 100644 index 0000000000000000000000000000000000000000..5b9bf31994d1d52be0c69857defb4584f90d7637 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.pl.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "automatyczny", + "manual": "r\u0119czny" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.pt-BR.json b/homeassistant/components/lametric/translations/select.pt-BR.json new file mode 100644 index 0000000000000000000000000000000000000000..dcf5c796e00c41527157952151d284eb7f63c908 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.pt-BR.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Autom\u00e1tico", + "manual": "Manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.ru.json b/homeassistant/components/lametric/translations/select.ru.json new file mode 100644 index 0000000000000000000000000000000000000000..b96768726590f1c889268071c2b9cc61e159dc11 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.ru.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438", + "manual": "\u0412\u0440\u0443\u0447\u043d\u0443\u044e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.tr.json b/homeassistant/components/lametric/translations/select.tr.json new file mode 100644 index 0000000000000000000000000000000000000000..42b1db54d35c68750e31b2565569d2074f348e28 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.tr.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "Otomatik", + "manual": "Manuel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/select.zh-Hant.json b/homeassistant/components/lametric/translations/select.zh-Hant.json new file mode 100644 index 0000000000000000000000000000000000000000..a7c9c771d6871255fe9c57fd00301249332b4d40 --- /dev/null +++ b/homeassistant/components/lametric/translations/select.zh-Hant.json @@ -0,0 +1,8 @@ +{ + "state": { + "lametric__brightness_mode": { + "auto": "\u81ea\u52d5", + "manual": "\u624b\u52d5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lametric/translations/sv.json b/homeassistant/components/lametric/translations/sv.json index 4ea1aa31e3f49440b6a9f1dcc6e9cd70df4b9ba7..e1597b90f8545fcaac5a2505b0b1b029224bcca5 100644 --- a/homeassistant/components/lametric/translations/sv.json +++ b/homeassistant/components/lametric/translations/sv.json @@ -7,7 +7,8 @@ "link_local_address": "Lokala l\u00e4nkadresser st\u00f6ds inte", "missing_configuration": "LaMetric-integrationen \u00e4r inte konfigurerad. V\u00e4nligen f\u00f6lj dokumentationen.", "no_devices": "Den auktoriserade anv\u00e4ndaren har inga LaMetric-enheter", - "no_url_available": "Ingen webbadress tillg\u00e4nglig. F\u00f6r information om detta fel, [kolla hj\u00e4lpavsnittet]({docs_url})" + "no_url_available": "Ingen webbadress tillg\u00e4nglig. F\u00f6r information om detta fel, [kolla hj\u00e4lpavsnittet]({docs_url})", + "unknown": "Ov\u00e4ntat fel" }, "error": { "cannot_connect": "Det gick inte att ansluta.", diff --git a/homeassistant/components/lametric/translations/tr.json b/homeassistant/components/lametric/translations/tr.json index 1801d6aac0828f16b7fbff26b75b452f35c4e829..5362e625f83ef1bc1ddbe6888d96379fc276add3 100644 --- a/homeassistant/components/lametric/translations/tr.json +++ b/homeassistant/components/lametric/translations/tr.json @@ -7,7 +7,10 @@ "link_local_address": "Ba\u011flant\u0131 yerel adresleri desteklenmiyor", "missing_configuration": "LaMetric entegrasyonu yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", "no_devices": "Yetkili kullan\u0131c\u0131n\u0131n LaMetric cihaz\u0131 yok", - "no_url_available": "Kullan\u0131labilir URL yok. Bu hata hakk\u0131nda bilgi i\u00e7in [yard\u0131m b\u00f6l\u00fcm\u00fcne bak\u0131n]({docs_url})" + "no_url_available": "Kullan\u0131labilir URL yok. Bu hata hakk\u0131nda bilgi i\u00e7in [yard\u0131m b\u00f6l\u00fcm\u00fcne bak\u0131n]({docs_url})", + "reauth_device_not_found": "Yeniden do\u011frulamaya \u00e7al\u0131\u015ft\u0131\u011f\u0131n\u0131z cihaz bu LaMetric hesab\u0131nda bulunamad\u0131", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "unknown": "Beklenmeyen hata" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", diff --git a/homeassistant/components/lametric/translations/zh-Hant.json b/homeassistant/components/lametric/translations/zh-Hant.json index bcaa67ed4adffae650382e7499725b1e83b03354..56697150747d78f92b976f133a0848d07613cd61 100644 --- a/homeassistant/components/lametric/translations/zh-Hant.json +++ b/homeassistant/components/lametric/translations/zh-Hant.json @@ -8,6 +8,8 @@ "missing_configuration": "LaMetric \u6574\u5408\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002", "no_devices": "\u8a8d\u8b49\u4f7f\u7528\u8005\u6c92\u6709\u4efb\u4f55 LaMetric \u88dd\u7f6e", "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})", + "reauth_device_not_found": "\u65bc\u6b64 LaMetric \u5e33\u865f\u5167\u627e\u4e0d\u5230\u6240\u8a66\u8457\u91cd\u65b0\u8a8d\u8b49\u7684\u88dd\u7f6e", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "error": { diff --git a/homeassistant/components/landisgyr_heat_meter/manifest.json b/homeassistant/components/landisgyr_heat_meter/manifest.json index 9d1faa570b7280b9de9b0fc15e48d72ee2a16e99..dc6444b478d91ee9b17ef6dca24cbf8b32b89463 100644 --- a/homeassistant/components/landisgyr_heat_meter/manifest.json +++ b/homeassistant/components/landisgyr_heat_meter/manifest.json @@ -3,7 +3,7 @@ "name": "Landis+Gyr Heat Meter", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter", - "requirements": ["ultraheat-api==0.4.3"], + "requirements": ["ultraheat-api==0.5.0"], "ssdp": [], "zeroconf": [], "homekit": {}, diff --git a/homeassistant/components/landisgyr_heat_meter/translations/nb.json b/homeassistant/components/landisgyr_heat_meter/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/landisgyr_heat_meter/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 2675371f033c824f30559a1eb6f6baa48c467539..497ccf817bc45e50fa1c4b7a651674dd085aed88 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -10,7 +10,7 @@ from pylast import WSError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -21,7 +21,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_LAST_PLAYED = "last_played" ATTR_PLAY_COUNT = "play_count" ATTR_TOP_PLAYED = "top_played" -ATTRIBUTION = "Data provided by Last.fm" STATE_NOT_SCROBBLING = "Not Scrobbling" @@ -64,6 +63,8 @@ def setup_platform( class LastfmSensor(SensorEntity): """A class for the Last.fm account.""" + _attr_attribution = "Data provided by Last.fm" + def __init__(self, user, lastfm_api): """Initialize the sensor.""" self._unique_id = hashlib.sha256(user.encode("utf-8")).hexdigest() @@ -117,7 +118,6 @@ class LastfmSensor(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" return { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_LAST_PLAYED: self._lastplayed, ATTR_PLAY_COUNT: self._playcount, ATTR_TOP_PLAYED: self._topplayed, diff --git a/homeassistant/components/launch_library/manifest.json b/homeassistant/components/launch_library/manifest.json index af0cf77007e41fea0ece0ed29c5e2df5b2ad9ada..12b743b22d1174b3289dfae4fea7ad2fdc7b5282 100644 --- a/homeassistant/components/launch_library/manifest.json +++ b/homeassistant/components/launch_library/manifest.json @@ -2,6 +2,7 @@ "domain": "launch_library", "name": "Launch Library", "config_flow": true, + "integration_type": "service", "documentation": "https://www.home-assistant.io/integrations/launch_library", "requirements": ["pylaunches==1.3.0"], "codeowners": ["@ludeeus", "@DurgNomis-drol"], diff --git a/homeassistant/components/laundrify/translations/nb.json b/homeassistant/components/laundrify/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/laundrify/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/led_ble/__init__.py b/homeassistant/components/led_ble/__init__.py index 5c454c6df7c8a35a855ed8f257bd50fb0051d4cf..9dd6529df0de953c46996cc53465ed6468d39c1c 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -43,7 +43,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: change: bluetooth.BluetoothChange, ) -> None: """Update from a ble callback.""" - led_ble.set_ble_device(service_info.device) + led_ble.set_ble_device_and_advertisement_data( + service_info.device, service_info.advertisement + ) entry.async_on_unload( bluetooth.async_register_callback( diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 65725ed482a28df0fbc375d4d90fb8032aea6955..6802eea9bc7509ee1d865ce83069600632ec0c45 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -2,8 +2,8 @@ "domain": "led_ble", "name": "LED BLE", "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/ble_ble", - "requirements": ["led-ble==0.10.1"], + "documentation": "https://www.home-assistant.io/integrations/led_ble/", + "requirements": ["led-ble==1.0.0"], "dependencies": ["bluetooth"], "codeowners": ["@bdraco"], "bluetooth": [ diff --git a/homeassistant/components/led_ble/translations/he.json b/homeassistant/components/led_ble/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..6dc5ae75df8282cca32f50dddd6f76abe398a532 --- /dev/null +++ b/homeassistant/components/led_ble/translations/he.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{name}" + } +} \ No newline at end of file diff --git a/homeassistant/components/led_ble/translations/nb.json b/homeassistant/components/led_ble/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/led_ble/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/legrand/manifest.json b/homeassistant/components/legrand/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..1437622e63250f92026df2aa4a806495dd507925 --- /dev/null +++ b/homeassistant/components/legrand/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "legrand", + "name": "Legrand", + "integration_type": "virtual", + "supported_by": "netatmo" +} diff --git a/homeassistant/components/lidarr/__init__.py b/homeassistant/components/lidarr/__init__.py index 6410e520b4295bd3bc2d8ee09026dc291fb1b385..9222164227bd651c84799763de56096097b196b1 100644 --- a/homeassistant/components/lidarr/__init__.py +++ b/homeassistant/components/lidarr/__init__.py @@ -1,6 +1,8 @@ """The Lidarr component.""" from __future__ import annotations +from typing import Any + from aiopyarr.lidarr_client import LidarrClient from aiopyarr.models.host_configuration import PyArrHostConfiguration @@ -18,6 +20,7 @@ from .coordinator import ( LidarrDataUpdateCoordinator, QueueDataUpdateCoordinator, StatusDataUpdateCoordinator, + T, WantedDataUpdateCoordinator, ) @@ -36,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass, host_configuration.verify_ssl), request_timeout=60, ) - coordinators: dict[str, LidarrDataUpdateCoordinator] = { + coordinators: dict[str, LidarrDataUpdateCoordinator[Any]] = { "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, lidarr), "queue": QueueDataUpdateCoordinator(hass, host_configuration, lidarr), "status": StatusDataUpdateCoordinator(hass, host_configuration, lidarr), @@ -63,13 +66,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class LidarrEntity(CoordinatorEntity[LidarrDataUpdateCoordinator]): +class LidarrEntity(CoordinatorEntity[LidarrDataUpdateCoordinator[T]]): """Defines a base Lidarr entity.""" _attr_has_entity_name = True def __init__( - self, coordinator: LidarrDataUpdateCoordinator, description: EntityDescription + self, + coordinator: LidarrDataUpdateCoordinator[T], + description: EntityDescription, ) -> None: """Initialize the Lidarr entity.""" super().__init__(coordinator) @@ -80,6 +85,6 @@ class LidarrEntity(CoordinatorEntity[LidarrDataUpdateCoordinator]): entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer=DEFAULT_NAME, - name=DEFAULT_NAME, + name=coordinator.config_entry.title, sw_version=coordinator.system_version, ) diff --git a/homeassistant/components/lidarr/coordinator.py b/homeassistant/components/lidarr/coordinator.py index be789c6a32ab5675b0b9816ca66e9a363004202d..c02d6525871f112ed070ccf0b2e9b6b4b7e59226 100644 --- a/homeassistant/components/lidarr/coordinator.py +++ b/homeassistant/components/lidarr/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import abstractmethod from datetime import timedelta -from typing import Generic, TypeVar, cast +from typing import Generic, TypeVar, Union, cast from aiopyarr import LidarrAlbum, LidarrQueue, LidarrRootFolder, exceptions from aiopyarr.lidarr_client import LidarrClient @@ -16,10 +16,10 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER -T = TypeVar("T", list[LidarrRootFolder], LidarrQueue, str, LidarrAlbum) +T = TypeVar("T", bound=Union[list[LidarrRootFolder], LidarrQueue, str, LidarrAlbum]) -class LidarrDataUpdateCoordinator(DataUpdateCoordinator, Generic[T]): +class LidarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T]): """Data update coordinator for the Lidarr integration.""" config_entry: ConfigEntry @@ -59,15 +59,19 @@ class LidarrDataUpdateCoordinator(DataUpdateCoordinator, Generic[T]): raise NotImplementedError -class DiskSpaceDataUpdateCoordinator(LidarrDataUpdateCoordinator): +class DiskSpaceDataUpdateCoordinator( + LidarrDataUpdateCoordinator[list[LidarrRootFolder]] +): """Disk space update coordinator for Lidarr.""" async def _fetch_data(self) -> list[LidarrRootFolder]: """Fetch the data.""" - return cast(list, await self.api_client.async_get_root_folders()) + return cast( + list[LidarrRootFolder], await self.api_client.async_get_root_folders() + ) -class QueueDataUpdateCoordinator(LidarrDataUpdateCoordinator): +class QueueDataUpdateCoordinator(LidarrDataUpdateCoordinator[LidarrQueue]): """Queue update coordinator.""" async def _fetch_data(self) -> LidarrQueue: @@ -75,7 +79,7 @@ class QueueDataUpdateCoordinator(LidarrDataUpdateCoordinator): return await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS) -class StatusDataUpdateCoordinator(LidarrDataUpdateCoordinator): +class StatusDataUpdateCoordinator(LidarrDataUpdateCoordinator[str]): """Status update coordinator for Lidarr.""" async def _fetch_data(self) -> str: @@ -83,7 +87,7 @@ class StatusDataUpdateCoordinator(LidarrDataUpdateCoordinator): return (await self.api_client.async_get_system_status()).version -class WantedDataUpdateCoordinator(LidarrDataUpdateCoordinator): +class WantedDataUpdateCoordinator(LidarrDataUpdateCoordinator[LidarrAlbum]): """Wanted update coordinator.""" async def _fetch_data(self) -> LidarrAlbum: diff --git a/homeassistant/components/lidarr/manifest.json b/homeassistant/components/lidarr/manifest.json index 7d4e9bcede76dcef795020ddb4ec51489044be5e..4c07e0e17629af9ace8bca606864afe4d4e5b374 100644 --- a/homeassistant/components/lidarr/manifest.json +++ b/homeassistant/components/lidarr/manifest.json @@ -2,7 +2,7 @@ "domain": "lidarr", "name": "Lidarr", "documentation": "https://www.home-assistant.io/integrations/lidarr", - "requirements": ["aiopyarr==22.9.0"], + "requirements": ["aiopyarr==22.10.0"], "codeowners": ["@tkdrob"], "config_flow": true, "iot_class": "local_polling", diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index 8529d9a646927a87bbac760fcbb52f507a213be5..2e5f9bb710fea024a1829ff758c71fff797f2f35 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -4,10 +4,9 @@ from __future__ import annotations from collections.abc import Callable from copy import deepcopy from dataclasses import dataclass -from datetime import datetime -from typing import Generic +from typing import Any, Generic -from aiopyarr import LidarrQueueItem, LidarrRootFolder +from aiopyarr import LidarrQueue, LidarrQueueItem, LidarrRootFolder from homeassistant.components.sensor import ( SensorEntity, @@ -18,7 +17,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import DATA_GIGABYTES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from . import LidarrEntity from .const import BYTE_SIZES, DOMAIN @@ -27,7 +25,7 @@ from .coordinator import LidarrDataUpdateCoordinator, T def get_space(data: list[LidarrRootFolder], name: str) -> str: """Get space.""" - space = [] + space: list[float] = [] for mount in data: if name in mount.path: mount.freeSpace = mount.freeSpace if mount.accessible else 0 @@ -36,8 +34,8 @@ def get_space(data: list[LidarrRootFolder], name: str) -> str: def get_modified_description( - description: LidarrSensorEntityDescription, mount: LidarrRootFolder -) -> tuple[LidarrSensorEntityDescription, str]: + description: LidarrSensorEntityDescription[T], mount: LidarrRootFolder +) -> tuple[LidarrSensorEntityDescription[T], str]: """Return modified description and folder name.""" desc = deepcopy(description) name = mount.path.rsplit("/")[-1].rsplit("\\")[-1] @@ -50,25 +48,23 @@ def get_modified_description( class LidarrSensorEntityDescriptionMixIn(Generic[T]): """Mixin for required keys.""" - value_fn: Callable[[T, str], str] + value_fn: Callable[[T, str], str | int] @dataclass class LidarrSensorEntityDescription( - SensorEntityDescription, LidarrSensorEntityDescriptionMixIn, Generic[T] + SensorEntityDescription, LidarrSensorEntityDescriptionMixIn[T], Generic[T] ): """Class to describe a Lidarr sensor.""" - attributes_fn: Callable[ - [T], dict[str, StateType | datetime] | None - ] = lambda _: None + attributes_fn: Callable[[T], dict[str, str] | None] = lambda _: None description_fn: Callable[ - [LidarrSensorEntityDescription, LidarrRootFolder], - tuple[LidarrSensorEntityDescription, str] | None, - ] = lambda _, __: None + [LidarrSensorEntityDescription[T], LidarrRootFolder], + tuple[LidarrSensorEntityDescription[T], str] | None, + ] | None = None -SENSOR_TYPES: dict[str, LidarrSensorEntityDescription] = { +SENSOR_TYPES: dict[str, LidarrSensorEntityDescription[Any]] = { "disk_space": LidarrSensorEntityDescription( key="disk_space", name="Disk space", @@ -78,7 +74,7 @@ SENSOR_TYPES: dict[str, LidarrSensorEntityDescription] = { state_class=SensorStateClass.TOTAL, description_fn=get_modified_description, ), - "queue": LidarrSensorEntityDescription( + "queue": LidarrSensorEntityDescription[LidarrQueue]( key="queue", name="Queue", native_unit_of_measurement="Albums", @@ -87,7 +83,7 @@ SENSOR_TYPES: dict[str, LidarrSensorEntityDescription] = { state_class=SensorStateClass.TOTAL, attributes_fn=lambda data: {i.title: queue_str(i) for i in data.records}, ), - "wanted": LidarrSensorEntityDescription( + "wanted": LidarrSensorEntityDescription[LidarrQueue]( key="wanted", name="Wanted", native_unit_of_measurement="Albums", @@ -108,10 +104,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Lidarr sensors based on a config entry.""" - coordinators: dict[str, LidarrDataUpdateCoordinator] = hass.data[DOMAIN][ + coordinators: dict[str, LidarrDataUpdateCoordinator[Any]] = hass.data[DOMAIN][ entry.entry_id ] - entities = [] + entities: list[LidarrSensor[Any]] = [] for coordinator_type, description in SENSOR_TYPES.items(): coordinator = coordinators[coordinator_type] if coordinator_type != "disk_space": @@ -125,15 +121,15 @@ async def async_setup_entry( async_add_entities(entities) -class LidarrSensor(LidarrEntity, SensorEntity): +class LidarrSensor(LidarrEntity[T], SensorEntity): """Implementation of the Lidarr sensor.""" - entity_description: LidarrSensorEntityDescription + entity_description: LidarrSensorEntityDescription[T] def __init__( self, - coordinator: LidarrDataUpdateCoordinator, - description: LidarrSensorEntityDescription, + coordinator: LidarrDataUpdateCoordinator[T], + description: LidarrSensorEntityDescription[T], folder_name: str = "", ) -> None: """Create Lidarr entity.""" @@ -141,12 +137,12 @@ class LidarrSensor(LidarrEntity, SensorEntity): self.folder_name = folder_name @property - def extra_state_attributes(self) -> dict[str, StateType | datetime] | None: + def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes of the sensor.""" return self.entity_description.attributes_fn(self.coordinator.data) @property - def native_value(self) -> StateType: + def native_value(self) -> str | int: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data, self.folder_name) diff --git a/homeassistant/components/lidarr/strings.json b/homeassistant/components/lidarr/strings.json index 662d930cbef54b889ebc3d54a26cb446a3a929bc..ffa91c23f2a92c87b4f3c3a6645ae5870ca7c2e9 100644 --- a/homeassistant/components/lidarr/strings.json +++ b/homeassistant/components/lidarr/strings.json @@ -28,15 +28,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } - }, - "options": { - "step": { - "init": { - "data": { - "upcoming_days": "Number of upcoming days to display on calendar", - "max_records": "Number of maximum records to display on wanted and queue" - } - } - } } } diff --git a/homeassistant/components/lidarr/translations/bg.json b/homeassistant/components/lidarr/translations/bg.json index 4e22178a11d18aee45873a22ff8c522bc0656154..040b54c06e1fed1dce1c5e2218ede21e6fcdda10 100644 --- a/homeassistant/components/lidarr/translations/bg.json +++ b/homeassistant/components/lidarr/translations/bg.json @@ -7,7 +7,8 @@ "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" + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430", + "zeroconf_failed": "API \u043a\u043b\u044e\u0447\u044a\u0442 \u043d\u0435 \u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d. \u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0433\u043e \u0440\u044a\u0447\u043d\u043e." }, "step": { "reauth_confirm": { diff --git a/homeassistant/components/lidarr/translations/ca.json b/homeassistant/components/lidarr/translations/ca.json index 78d0904b50a1190d1364b597f12dc05bab7f30aa..9cc30d6f893d62fc661607554f6e4d803bfa1b2a 100644 --- a/homeassistant/components/lidarr/translations/ca.json +++ b/homeassistant/components/lidarr/translations/ca.json @@ -33,6 +33,7 @@ "step": { "init": { "data": { + "max_records": "Nombre m\u00e0xim de registres a mostrar a la cua i a desitjats", "upcoming_days": "Nombre dies propers a mostrar al calendari" } } diff --git a/homeassistant/components/lidarr/translations/et.json b/homeassistant/components/lidarr/translations/et.json new file mode 100644 index 0000000000000000000000000000000000000000..0a88c87659f71cb937ba73c3473690c791704a9a --- /dev/null +++ b/homeassistant/components/lidarr/translations/et.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Teenus on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge", + "wrong_app": "Vale rakendus. Palun proovi uuesti", + "zeroconf_failed": "API v\u00f5tit ei leitud. Sisesta see k\u00e4sitsi" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API v\u00f5ti" + }, + "description": "Lidarri sidumine tuleb Lidarr API-ga k\u00e4sitsi uuesti autentida", + "title": "Taastuvasta sidumine" + }, + "user": { + "data": { + "api_key": "API v\u00f5ti", + "url": "URL", + "verify_ssl": "Kontrolli SSL serte" + }, + "description": "API-v\u00f5tme saab automaatselt alla laadida, kui rakenduses pole sisselogimismandaate m\u00e4\u00e4ratud.\n API-v\u00f5tme leiate Lidarri veebikasutajaliidese jaotisest Seaded > \u00dcldine." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "max_records": "Soovitud ja j\u00e4rjekorras kuvatavate kirjete maksimaalne arv", + "upcoming_days": "Kalendris kuvatavate eelseisvate p\u00e4evade arv" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lidarr/translations/ja.json b/homeassistant/components/lidarr/translations/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..7ecdda821ea9bbc93ed1cc09009788a9f42a1396 --- /dev/null +++ b/homeassistant/components/lidarr/translations/ja.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API\u30ad\u30fc" + }, + "title": "\u7d71\u5408\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "url": "URL", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lidarr/translations/nb.json b/homeassistant/components/lidarr/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..885ce0bcf88216ecc3d2ad83de98c5451121f3c3 --- /dev/null +++ b/homeassistant/components/lidarr/translations/nb.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig autentisering", + "unknown": "Uventet feil" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-n\u00f8kkel" + } + }, + "user": { + "data": { + "api_key": "API-n\u00f8kkel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lidarr/translations/no.json b/homeassistant/components/lidarr/translations/no.json index 745140564851633eb5513ddc2a83374bee33f6bd..23c63f562b55eb80e48f313ec0d7fc45b9e43bfa 100644 --- a/homeassistant/components/lidarr/translations/no.json +++ b/homeassistant/components/lidarr/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Tjenesten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/lidarr/translations/pt-BR.json b/homeassistant/components/lidarr/translations/pt-BR.json index 9390e86b4978de5d94021bc227fc067027702574..5d9b99704c416e856f47ab6c2a058314762a47bf 100644 --- a/homeassistant/components/lidarr/translations/pt-BR.json +++ b/homeassistant/components/lidarr/translations/pt-BR.json @@ -5,7 +5,7 @@ "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", "unknown": "Erro inesperado", "wrong_app": "Aplica\u00e7\u00e3o incorreta alcan\u00e7ada. Por favor, tente novamente", @@ -14,16 +14,16 @@ "step": { "reauth_confirm": { "data": { - "api_key": "Chave de API" + "api_key": "Chave da API" }, "description": "A integra\u00e7\u00e3o do Lidarr precisa ser autenticada manualmente com a API do Lidarr", "title": "Reautenticar Integra\u00e7\u00e3o" }, "user": { "data": { - "api_key": "Chave de API", + "api_key": "Chave da API", "url": "URL", - "verify_ssl": "Verificar certificado SSL" + "verify_ssl": "Verifique o certificado SSL" }, "description": "A chave de API pode ser recuperada automaticamente se as credenciais de login n\u00e3o tiverem sido definidas no aplicativo.\n Sua chave de API pode ser encontrada em Configura\u00e7\u00f5es > Geral na IU da Web do Lidarr." } diff --git a/homeassistant/components/lidarr/translations/sv.json b/homeassistant/components/lidarr/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..6e87010feae3b593e4e6ec763e454e7854b2d184 --- /dev/null +++ b/homeassistant/components/lidarr/translations/sv.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Tj\u00e4nsten \u00e4r redan konfigurerad", + "reauth_successful": "\u00c5terautentisering lyckades" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel", + "wrong_app": "Felaktig ans\u00f6kan har n\u00e5tts. F\u00f6rs\u00f6k igen.", + "zeroconf_failed": "API-nyckeln har inte hittats. Ange den manuellt" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + }, + "description": "Lidarr-integrationen m\u00e5ste \u00e5terautentiseras manuellt med Lidarr API", + "title": "\u00c5terautenticera integration" + }, + "user": { + "data": { + "api_key": "API-nyckel", + "url": "URL", + "verify_ssl": "Verifiera SSL-certifikat" + }, + "description": "API-nyckel kan h\u00e4mtas automatiskt om inloggningsuppgifter inte st\u00e4llts in i applikationen.\n Din API-nyckel finns i Inst\u00e4llningar > Allm\u00e4nt i Lidarr Web UI." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "max_records": "Antal maximala poster att visa p\u00e5 \u00f6nskad och k\u00f6", + "upcoming_days": "Antal kommande dagar att visa i kalendern" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lidarr/translations/tr.json b/homeassistant/components/lidarr/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..39785efb9b0139d01918eb41b68b21c1b3f027d7 --- /dev/null +++ b/homeassistant/components/lidarr/translations/tr.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Hizmet 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", + "wrong_app": "Yanl\u0131\u015f uygulamaya ula\u015f\u0131ld\u0131. L\u00fctfen tekrar deneyin", + "zeroconf_failed": "API anahtar\u0131 bulunamad\u0131. L\u00fctfen manuel olarak girin" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API Anahtar\u0131" + }, + "description": "Lidarr entegrasyonunun, Lidarr API ile manuel olarak yeniden do\u011frulanmas\u0131 gerekiyor", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "url": "URL", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "description": "Giri\u015f kimlik bilgileri uygulamada ayarlanmad\u0131ysa API anahtar\u0131 otomatik olarak al\u0131nabilir.\n API anahtar\u0131n\u0131z, Lidarr Web Kullan\u0131c\u0131 Aray\u00fcz\u00fcndeki Ayarlar > Genel b\u00f6l\u00fcm\u00fcnde bulunabilir." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "max_records": "Aranan ve kuyrukta g\u00f6r\u00fcnt\u00fclenecek maksimum kay\u0131t say\u0131s\u0131", + "upcoming_days": "Takvimde g\u00f6r\u00fcnt\u00fclenecek yakla\u015fan g\u00fcn say\u0131s\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/const.py b/homeassistant/components/life360/const.py index 21bf9a89c5ece991e1fe63a59012194c1bf13fe0..d148a06c63433d90a9cc7952dc5ca2d7f0a85b52 100644 --- a/homeassistant/components/life360/const.py +++ b/homeassistant/components/life360/const.py @@ -7,7 +7,7 @@ DOMAIN = "life360" LOGGER = logging.getLogger(__package__) ATTRIBUTION = "Data provided by life360.com" -COMM_TIMEOUT = 3.05 +COMM_TIMEOUT = 10 SPEED_FACTOR_MPH = 2.25 SPEED_DIGITS = 1 UPDATE_INTERVAL = timedelta(seconds=10) diff --git a/homeassistant/components/life360/coordinator.py b/homeassistant/components/life360/coordinator.py index edb86e9727a3773788181288d760144cb8ffe3ba..0b9641bfcae6c189cdd8eeb1684668274743a7a3 100644 --- a/homeassistant/components/life360/coordinator.py +++ b/homeassistant/components/life360/coordinator.py @@ -23,6 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter +from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( COMM_TIMEOUT, @@ -115,10 +116,10 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator[Life360Data]): return await getattr(self._api, func)(*args) except LoginError as exc: LOGGER.debug("Login error: %s", exc) - raise ConfigEntryAuthFailed from exc + raise ConfigEntryAuthFailed(exc) from exc except Life360Error as exc: LOGGER.debug("%s: %s", exc.__class__.__name__, exc) - raise UpdateFailed from exc + raise UpdateFailed(exc) from exc async def _async_update_data(self) -> Life360Data: """Get & process data from Life360.""" @@ -206,7 +207,7 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator[Life360Data]): address = address1 or address2 speed = max(0, float(loc["speed"]) * SPEED_FACTOR_MPH) - if self._hass.config.units.is_metric: + if self._hass.config.units is METRIC_SYSTEM: speed = DistanceConverter.convert( speed, LENGTH_MILES, LENGTH_KILOMETERS ) diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index 2c05b944a27cbe87f7653bb80db851f22bc9c6eb..a6ca0a16aa363b9176d647ee8cc5ee7544590656 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -87,8 +87,7 @@ async def async_setup_entry( and not new_members_only ): new_entities.append(Life360DeviceTracker(coordinator, member_id)) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) process_data(new_members_only=False) entry.async_on_unload(coordinator.async_add_listener(process_data)) diff --git a/homeassistant/components/life360/translations/nb.json b/homeassistant/components/life360/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/life360/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/life360/translations/no.json b/homeassistant/components/life360/translations/no.json index 7213a6656070bafb053f91ac0d3ca3fbe078679d..5095ced59f06d5fffa10bd1fe12b372e79eaa4ef 100644 --- a/homeassistant/components/life360/translations/no.json +++ b/homeassistant/components/life360/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Kontoen er allerede konfigurert", "invalid_auth": "Ugyldig godkjenning", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "unknown": "Uventet feil" }, "create_entry": { diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 2f20cb0e36694c3c0ffdaa1f471f97512fe9debe..786ddd6abbf01c9e3bc11d5095de997abdca9045 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -57,7 +57,13 @@ CONFIG_SCHEMA = vol.All( ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT, Platform.SELECT] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.LIGHT, + Platform.SELECT, + Platform.SENSOR, +] DISCOVERY_INTERVAL = timedelta(minutes=15) MIGRATION_INTERVAL = timedelta(minutes=5) @@ -199,6 +205,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator.async_setup() try: await coordinator.async_config_entry_first_refresh() + await coordinator.sensor_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: connection.async_stop() raise diff --git a/homeassistant/components/lifx/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py index 273ef035757f812327996d75ae75fad4237634db..bdc2c9a1ffa6a367eb0e4d58113b5ad03c3011ab 100644 --- a/homeassistant/components/lifx/binary_sensor.py +++ b/homeassistant/components/lifx/binary_sensor.py @@ -12,8 +12,8 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, HEV_CYCLE_STATE -from .coordinator import LIFXUpdateCoordinator -from .entity import LIFXEntity +from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator +from .entity import LIFXSensorEntity from .util import lifx_features HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription( @@ -34,28 +34,28 @@ async def async_setup_entry( async_add_entities( [ LIFXHevCycleBinarySensorEntity( - coordinator=coordinator, description=HEV_CYCLE_STATE_SENSOR + coordinator=coordinator.sensor_coordinator, + description=HEV_CYCLE_STATE_SENSOR, ) ] ) -class LIFXHevCycleBinarySensorEntity(LIFXEntity, BinarySensorEntity): +class LIFXHevCycleBinarySensorEntity(LIFXSensorEntity, BinarySensorEntity): """LIFX HEV cycle state binary sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: LIFXUpdateCoordinator, + coordinator: LIFXSensorUpdateCoordinator, description: BinarySensorEntityDescription, ) -> None: """Initialise the sensor.""" super().__init__(coordinator) - self.entity_description = description self._attr_name = description.name - self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" self._async_update_attrs() @callback diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py index 76afdc785e9d48abc0d65957c55447debcd065fe..4d917009c5d52e1d708bc2298ee13405643e5035 100644 --- a/homeassistant/components/lifx/button.py +++ b/homeassistant/components/lifx/button.py @@ -12,8 +12,8 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, IDENTIFY, RESTART -from .coordinator import LIFXUpdateCoordinator -from .entity import LIFXEntity +from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator +from .entity import LIFXSensorEntity RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription( key=RESTART, @@ -38,20 +38,22 @@ async def async_setup_entry( domain_data = hass.data[DOMAIN] coordinator: LIFXUpdateCoordinator = domain_data[entry.entry_id] async_add_entities( - cls(coordinator) for cls in (LIFXRestartButton, LIFXIdentifyButton) + cls(coordinator.sensor_coordinator) + for cls in (LIFXRestartButton, LIFXIdentifyButton) ) -class LIFXButton(LIFXEntity, ButtonEntity): +class LIFXButton(LIFXSensorEntity, ButtonEntity): """Base LIFX button.""" _attr_has_entity_name: bool = True + _attr_should_poll: bool = False - def __init__(self, coordinator: LIFXUpdateCoordinator) -> None: + def __init__(self, coordinator: LIFXSensorUpdateCoordinator) -> None: """Initialise a LIFX button.""" super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator.serial_number}_{self.entity_description.key}" + f"{coordinator.parent.serial_number}_{self.entity_description.key}" ) diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 8acfa35802e501635817f53f133e2897d41ddb6f..af9dfa5a2779e1f2767aea77677a49b3e4ee09e6 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -12,6 +12,7 @@ MESSAGE_RETRIES = 5 OVERALL_TIMEOUT = 9 UNAVAILABLE_GRACE = 90 +CONF_LABEL = "label" CONF_SERIAL = "serial" IDENTIFY_WAVEFORM = { @@ -34,8 +35,11 @@ ATTR_INDICATION = "indication" ATTR_INFRARED = "infrared" ATTR_POWER = "power" ATTR_REMAINING = "remaining" +ATTR_RSSI = "rssi" ATTR_ZONES = "zones" +ATTR_THEME = "theme" + HEV_CYCLE_STATE = "hev_cycle_state" INFRARED_BRIGHTNESS = "infrared_brightness" INFRARED_BRIGHTNESS_VALUES_MAP = { diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index a6d61d91d289883028f903fb933067b2b64e1fdb..9343c3b7dade5ac4ede93a61fd67278a4420ea4d 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -2,16 +2,30 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta from enum import IntEnum from functools import partial +from math import floor, log10 from typing import Any, cast -from aiolifx.aiolifx import Light, MultiZoneDirection, MultiZoneEffectType +from aiolifx.aiolifx import ( + Light, + MultiZoneDirection, + MultiZoneEffectType, + TileEffectType, +) from aiolifx.connection import LIFXConnection +from aiolifx_themes.themes import ThemeLibrary, ThemePainter +from awesomeversion import AwesomeVersion -from homeassistant.const import Platform +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + Platform, +) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -34,8 +48,11 @@ from .util import ( lifx_features, ) +LIGHT_UPDATE_INTERVAL = 10 +SENSOR_UPDATE_INTERVAL = 30 REQUEST_REFRESH_DELAY = 0.35 LIFX_IDENTIFY_DELAY = 3.0 +RSSI_DBM_FW = AwesomeVersion("2.77") class FirmwareEffect(IntEnum): @@ -62,13 +79,13 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): self.device: Light = connection.device self.lock = asyncio.Lock() self.active_effect = FirmwareEffect.OFF - update_interval = timedelta(seconds=10) + self.sensor_coordinator = LIFXSensorUpdateCoordinator(hass, self, title) super().__init__( hass, _LOGGER, name=f"{title} ({self.device.ip_addr})", - update_interval=update_interval, + update_interval=timedelta(seconds=LIGHT_UPDATE_INTERVAL), # We don't want an immediate refresh since the device # takes a moment to reflect the state change request_refresh_debouncer=Debouncer( @@ -104,10 +121,43 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): """Return the label of the bulb.""" return cast(str, self.device.label) - @property - def current_infrared_brightness(self) -> str | None: - """Return the current infrared brightness as a string.""" - return infrared_brightness_value_to_option(self.device.infrared_brightness) + async def diagnostics(self) -> dict[str, Any]: + """Return diagnostic information about the device.""" + features = lifx_features(self.device) + device_data = { + "firmware": self.device.host_firmware_version, + "vendor": self.device.vendor, + "product_id": self.device.product, + "features": features, + "hue": self.device.color[0], + "saturation": self.device.color[1], + "brightness": self.device.color[2], + "kelvin": self.device.color[3], + "power": self.device.power_level, + } + + if features["multizone"] is True: + zones = {"count": self.device.zones_count, "state": {}} + for index, zone_color in enumerate(self.device.color_zones): + zones["state"][index] = { + "hue": zone_color[0], + "saturation": zone_color[1], + "brightness": zone_color[2], + "kelvin": zone_color[3], + } + device_data["zones"] = zones + + if features["hev"] is True: + device_data["hev"] = { + "hev_cycle": self.device.hev_cycle, + "hev_config": self.device.hev_cycle_configuration, + "last_result": self.device.last_hev_cycle_result, + } + + if features["infrared"] is True: + device_data["infrared"] = {"brightness": self.device.infrared_brightness} + + return device_data def async_get_entity_id(self, platform: Platform, key: str) -> str | None: """Return the entity_id from the platform and key provided.""" @@ -116,19 +166,6 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): platform, DOMAIN, f"{self.serial_number}_{key}" ) - async def async_identify_bulb(self) -> None: - """Identify the device by flashing it three times.""" - bulb: Light = self.device - if bulb.power_level: - # just flash the bulb for three seconds - await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) - return - # Turn the bulb on first, flash for 3 seconds, then turn off - await self.async_set_power(state=True, duration=1) - await self.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) - await asyncio.sleep(LIFX_IDENTIFY_DELAY) - await self.async_set_power(state=False, duration=1) - async def _async_update_data(self) -> None: """Fetch all device data from the api.""" async with self.lock: @@ -148,18 +185,16 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): if self.device.mac_addr == TARGET_ANY: self.device.mac_addr = response.target_addr - # Update model-specific configuration - if lifx_features(self.device)["multizone"]: - await self.async_update_color_zones() - await self.async_update_multizone_effect() - - if lifx_features(self.device)["hev"]: - await self.async_get_hev_cycle() + # Update extended multizone devices + if lifx_features(self.device)["extended_multizone"]: + await self.async_get_extended_color_zones() + await self.async_get_multizone_effect() + # use legacy methods for older devices + elif lifx_features(self.device)["multizone"]: + await self.async_get_color_zones() + await self.async_get_multizone_effect() - if lifx_features(self.device)["infrared"]: - response = await async_execute_lifx(self.device.get_infrared) - - async def async_update_color_zones(self) -> None: + async def async_get_color_zones(self) -> None: """Get updated color information for each zone.""" zone = 0 top = 1 @@ -175,16 +210,14 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): if zone == top - 1: zone -= 1 - def async_get_hev_cycle_state(self) -> bool | None: - """Return the current HEV cycle state.""" - if self.device.hev_cycle is None: - return None - return bool(self.device.hev_cycle.get(ATTR_REMAINING, 0) > 0) - - async def async_get_hev_cycle(self) -> None: - """Update the HEV cycle status from a LIFX Clean bulb.""" - if lifx_features(self.device)["hev"]: - await async_execute_lifx(self.device.get_hev_cycle) + async def async_get_extended_color_zones(self) -> None: + """Get updated color information for all zones.""" + try: + await async_execute_lifx(self.device.get_extended_color_zones) + except asyncio.TimeoutError as ex: + raise HomeAssistantError( + f"Timeout getting color zones from {self.name}" + ) from ex async def async_set_waveform_optional( self, value: dict[str, Any], rapid: bool = False @@ -232,19 +265,57 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): ) ) - async def async_update_multizone_effect(self) -> None: + async def async_set_extended_color_zones( + self, + colors: list[tuple[int | float, int | float, int | float, int | float]], + colors_count: int | None = None, + duration: int = 0, + apply: int = 1, + ) -> None: + """Send a single set extended color zones message to the device.""" + + if colors_count is None: + colors_count = len(colors) + + # pad the color list with blanks if necessary + if len(colors) < 82: + for _ in range(82 - len(colors)): + colors.append((0, 0, 0, 0)) + + await async_execute_lifx( + partial( + self.device.set_extended_color_zones, + colors=colors, + colors_count=colors_count, + duration=duration, + apply=apply, + ) + ) + + async def async_get_multizone_effect(self) -> None: """Update the device firmware effect running state.""" await async_execute_lifx(self.device.get_multizone_effect) self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] async def async_set_multizone_effect( - self, effect: str, speed: float, direction: str, power_on: bool = True + self, + effect: str, + speed: float = 3.0, + direction: str = "RIGHT", + theme_name: str | None = None, + power_on: bool = True, ) -> None: """Control the firmware-based Move effect on a multizone device.""" if lifx_features(self.device)["multizone"] is True: if power_on and self.device.power_level == 0: await self.async_set_power(True, 0) + if theme_name is not None: + theme = ThemeLibrary().get_theme(theme_name) + await ThemePainter(self.hass.loop).paint( + theme, [self.device], round(speed) + ) + await async_execute_lifx( partial( self.device.set_multizone_effect, @@ -255,10 +326,138 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): ) self.active_effect = FirmwareEffect[effect.upper()] + async def async_set_matrix_effect( + self, + effect: str, + palette: list[tuple[int, int, int, int]] | None = None, + speed: float = 3, + power_on: bool = True, + ) -> None: + """Control the firmware-based effects on a matrix device.""" + if lifx_features(self.device)["matrix"] is True: + if power_on and self.device.power_level == 0: + await self.async_set_power(True, 0) + + if palette is None: + palette = [] + + await async_execute_lifx( + partial( + self.device.set_tile_effect, + effect=TileEffectType[effect.upper()].value, + speed=speed, + palette=palette, + ) + ) + self.active_effect = FirmwareEffect[effect.upper()] + def async_get_active_effect(self) -> int: """Return the enum value of the currently active firmware effect.""" return self.active_effect.value + +class LIFXSensorUpdateCoordinator(DataUpdateCoordinator): + """DataUpdateCoordinator to gather data for a specific lifx device.""" + + def __init__( + self, + hass: HomeAssistant, + parent: LIFXUpdateCoordinator, + title: str, + ) -> None: + """Initialize DataUpdateCoordinator.""" + self.parent: LIFXUpdateCoordinator = parent + self.device: Light = parent.device + self._update_rssi: bool = False + self._rssi: int = 0 + self.last_used_theme: str = "" + + super().__init__( + hass, + _LOGGER, + name=f"{title} Sensors ({self.device.ip_addr})", + update_interval=timedelta(seconds=SENSOR_UPDATE_INTERVAL), + # Refresh immediately because the changes are not visible + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=0, immediate=True + ), + ) + + @property + def rssi(self) -> int: + """Return stored RSSI value.""" + return self._rssi + + @property + def rssi_uom(self) -> str: + """Return the RSSI unit of measurement.""" + if AwesomeVersion(self.device.host_firmware_version) <= RSSI_DBM_FW: + return SIGNAL_STRENGTH_DECIBELS + + return SIGNAL_STRENGTH_DECIBELS_MILLIWATT + + @property + def current_infrared_brightness(self) -> str | None: + """Return the current infrared brightness as a string.""" + return infrared_brightness_value_to_option(self.device.infrared_brightness) + + async def _async_update_data(self) -> None: + """Fetch all device data from the api.""" + + if self._update_rssi is True: + await self.async_update_rssi() + + if lifx_features(self.device)["hev"]: + await self.async_get_hev_cycle() + + if lifx_features(self.device)["infrared"]: + await async_execute_lifx(self.device.get_infrared) + + async def async_set_infrared_brightness(self, option: str) -> None: + """Set infrared brightness.""" + infrared_brightness = infrared_brightness_option_to_value(option) + await async_execute_lifx(partial(self.device.set_infrared, infrared_brightness)) + + async def async_identify_bulb(self) -> None: + """Identify the device by flashing it three times.""" + bulb: Light = self.device + if bulb.power_level: + # just flash the bulb for three seconds + await self.parent.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) + return + # Turn the bulb on first, flash for 3 seconds, then turn off + await self.parent.async_set_power(state=True, duration=1) + await self.parent.async_set_waveform_optional(value=IDENTIFY_WAVEFORM) + await asyncio.sleep(LIFX_IDENTIFY_DELAY) + await self.parent.async_set_power(state=False, duration=1) + + def async_enable_rssi_updates(self) -> Callable[[], None]: + """Enable RSSI signal strength updates.""" + + @callback + def _async_disable_rssi_updates() -> None: + """Disable RSSI updates when sensor removed.""" + self._update_rssi = False + + self._update_rssi = True + return _async_disable_rssi_updates + + async def async_update_rssi(self) -> None: + """Update RSSI value.""" + resp = await async_execute_lifx(self.device.get_wifiinfo) + self._rssi = int(floor(10 * log10(resp.signal) + 0.5)) + + def async_get_hev_cycle_state(self) -> bool | None: + """Return the current HEV cycle state.""" + if self.device.hev_cycle is None: + return None + return bool(self.device.hev_cycle.get(ATTR_REMAINING, 0) > 0) + + async def async_get_hev_cycle(self) -> None: + """Update the HEV cycle status from a LIFX Clean bulb.""" + if lifx_features(self.device)["hev"]: + await async_execute_lifx(self.device.get_hev_cycle) + async def async_set_hev_cycle_state(self, enable: bool, duration: int = 0) -> None: """Start or stop an HEV cycle on a LIFX Clean bulb.""" if lifx_features(self.device)["hev"]: @@ -266,7 +465,8 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): partial(self.device.set_hev_cycle, enable=enable, duration=duration) ) - async def async_set_infrared_brightness(self, option: str) -> None: - """Set infrared brightness.""" - infrared_brightness = infrared_brightness_option_to_value(option) - await async_execute_lifx(partial(self.device.set_infrared, infrared_brightness)) + async def async_apply_theme(self, theme_name: str) -> None: + """Apply the selected theme to the device.""" + self.last_used_theme = theme_name + theme = ThemeLibrary().get_theme(theme_name) + await ThemePainter(self.hass.loop).paint(theme, [self.parent.device]) diff --git a/homeassistant/components/lifx/diagnostics.py b/homeassistant/components/lifx/diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..abe13cd1a5095d90d797cf59ce93439efeb24c74 --- /dev/null +++ b/homeassistant/components/lifx/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for LIFX.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_MAC +from homeassistant.core import HomeAssistant + +from .const import CONF_LABEL, DOMAIN +from .coordinator import LIFXUpdateCoordinator + +TO_REDACT = [CONF_LABEL, CONF_HOST, CONF_IP_ADDRESS, CONF_MAC] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a LIFX config entry.""" + coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + return { + "entry": { + "title": entry.title, + "data": async_redact_data(dict(entry.data), TO_REDACT), + }, + "data": async_redact_data(await coordinator.diagnostics(), TO_REDACT), + } diff --git a/homeassistant/components/lifx/discovery.py b/homeassistant/components/lifx/discovery.py index 6e1507c92ca7f9b4bc6dda0d84ff0aa344997b11..a4072ee23effbba151b3204aa3ac7dc02d547fb4 100644 --- a/homeassistant/components/lifx/discovery.py +++ b/homeassistant/components/lifx/discovery.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Collection, Iterable from aiolifx.aiolifx import LifxDiscovery, Light, ScanManager @@ -17,7 +17,7 @@ from .const import CONF_SERIAL, DOMAIN DEFAULT_TIMEOUT = 8.5 -async def async_discover_devices(hass: HomeAssistant) -> Iterable[Light]: +async def async_discover_devices(hass: HomeAssistant) -> Collection[Light]: """Discover lifx devices.""" all_lights: dict[str, Light] = {} broadcast_addrs = await network.async_get_ipv4_broadcast_addresses(hass) diff --git a/homeassistant/components/lifx/entity.py b/homeassistant/components/lifx/entity.py index 0007ab998a96abb93cb070d1050adf92bec99a13..a500e353fbf3d8318284cf4c840a4dc891e6caee 100644 --- a/homeassistant/components/lifx/entity.py +++ b/homeassistant/components/lifx/entity.py @@ -8,7 +8,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import LIFXUpdateCoordinator +from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): @@ -26,3 +26,20 @@ class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): model=products.product_map.get(self.bulb.product, "LIFX Bulb"), sw_version=self.bulb.host_firmware_version, ) + + +class LIFXSensorEntity(CoordinatorEntity[LIFXSensorUpdateCoordinator]): + """Representation of a LIFX sensor entity with a sensor coordinator.""" + + def __init__(self, coordinator: LIFXSensorUpdateCoordinator) -> None: + """Initialise the sensor.""" + super().__init__(coordinator) + self.bulb = coordinator.parent.device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.parent.serial_number)}, + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.parent.mac_address)}, + manufacturer="LIFX", + name=coordinator.parent.label, + model=products.product_map.get(self.bulb.product, "LIFX Bulb"), + sw_version=self.bulb.host_firmware_version, + ) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index aa02e42a9bfd8101e4e2b0c01a231810cc91d4f8..7b23e1d34c4592a20064f1d866c66e9ed82ebe7e 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from datetime import datetime, timedelta -import math from typing import Any import aiolifx_effects as aiolifx_effects_module @@ -26,7 +25,6 @@ from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time -import homeassistant.util.color as color_util from .const import ( _LOGGER, @@ -42,6 +40,8 @@ from .coordinator import FirmwareEffect, LIFXUpdateCoordinator from .entity import LIFXEntity from .manager import ( SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_MORPH, SERVICE_EFFECT_MOVE, SERVICE_EFFECT_PULSE, SERVICE_EFFECT_STOP, @@ -95,8 +95,12 @@ async def async_setup_entry( LIFX_SET_HEV_CYCLE_STATE_SCHEMA, "set_hev_cycle_state", ) - if lifx_features(device)["multizone"]: - entity: LIFXLight = LIFXStrip(coordinator, manager, entry) + if lifx_features(device)["matrix"]: + entity: LIFXLight = LIFXMatrix(coordinator, manager, entry) + elif lifx_features(device)["extended_multizone"]: + entity = LIFXExtendedMultiZone(coordinator, manager, entry) + elif lifx_features(device)["multizone"]: + entity = LIFXMultiZone(coordinator, manager, entry) elif lifx_features(device)["color"]: entity = LIFXColor(coordinator, manager, entry) else: @@ -128,16 +132,13 @@ class LIFXLight(LIFXEntity, LightEntity): self.entry = entry self._attr_unique_id = self.coordinator.serial_number self._attr_name = self.bulb.label - self._attr_min_mireds = math.floor( - color_util.color_temperature_kelvin_to_mired(bulb_features["max_kelvin"]) - ) - self._attr_max_mireds = math.ceil( - color_util.color_temperature_kelvin_to_mired(bulb_features["min_kelvin"]) - ) + self._attr_min_color_temp_kelvin = bulb_features["min_kelvin"] + self._attr_max_color_temp_kelvin = bulb_features["max_kelvin"] if bulb_features["min_kelvin"] != bulb_features["max_kelvin"]: color_mode = ColorMode.COLOR_TEMP else: color_mode = ColorMode.BRIGHTNESS + self._attr_color_mode = color_mode self._attr_supported_color_modes = {color_mode} self._attr_effect = None @@ -149,11 +150,9 @@ class LIFXLight(LIFXEntity, LightEntity): return convert_16_to_8(int(fade * self.bulb.color[HSBK_BRIGHTNESS])) @property - def color_temp(self) -> int | None: - """Return the color temperature.""" - return color_util.color_temperature_kelvin_to_mired( - self.bulb.color[HSBK_KELVIN] - ) + def color_temp_kelvin(self) -> int | None: + """Return the color temperature of this light in kelvin.""" + return int(self.bulb.color[HSBK_KELVIN]) @property def is_on(self) -> bool: @@ -272,7 +271,9 @@ class LIFXLight(LIFXEntity, LightEntity): "This device does not support setting HEV cycle state" ) - await self.coordinator.async_set_hev_cycle_state(power, duration or 0) + await self.coordinator.sensor_coordinator.async_set_hev_cycle_state( + power, duration or 0 + ) await self.update_during_transition(duration or 0) async def set_power( @@ -362,8 +363,8 @@ class LIFXColor(LIFXLight): return (hue, sat) if sat else None -class LIFXStrip(LIFXColor): - """Representation of a LIFX light strip with multiple zones.""" +class LIFXMultiZone(LIFXColor): + """Representation of a legacy LIFX multizone device.""" _attr_effect_list = [ SERVICE_EFFECT_COLORLOOP, @@ -426,16 +427,65 @@ class LIFXStrip(LIFXColor): ) from ex # set_color_zones does not update the - # state of the bulb, so we need to do that + # state of the device, so we need to do that await self.get_color() async def update_color_zones( self, ) -> None: - """Send a get color zones message to the bulb.""" + """Send a get color zones message to the device.""" + try: + await self.coordinator.async_get_color_zones() + except asyncio.TimeoutError as ex: + raise HomeAssistantError( + f"Timeout getting color zones from {self.name}" + ) from ex + + +class LIFXExtendedMultiZone(LIFXMultiZone): + """Representation of a LIFX device that supports extended multizone messages.""" + + async def set_color( + self, hsbk: list[float | int | None], kwargs: dict[str, Any], duration: int = 0 + ) -> None: + """Set colors on all zones of the device.""" + + # trigger an update of all zone values before merging new values + await self.coordinator.async_get_extended_color_zones() + + color_zones = self.bulb.color_zones + if (zones := kwargs.get(ATTR_ZONES)) is None: + # merge the incoming hsbk across all zones + for index, zone in enumerate(color_zones): + color_zones[index] = merge_hsbk(zone, hsbk) + else: + # merge the incoming HSBK with only the specified zones + for index, zone in enumerate(color_zones): + if index in zones: + color_zones[index] = merge_hsbk(zone, hsbk) + + # send the updated color zones list to the device try: - await self.coordinator.async_update_color_zones() + await self.coordinator.async_set_extended_color_zones( + color_zones, duration=duration + ) except asyncio.TimeoutError as ex: raise HomeAssistantError( - f"Timeout setting updating color zones for {self.name}" + f"Timeout setting color zones on {self.name}" ) from ex + + # set_extended_color_zones does not update the + # state of the device, so we need to do that + await self.get_color() + + +class LIFXMatrix(LIFXColor): + """Representation of a LIFX matrix device.""" + + _attr_effect_list = [ + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_MORPH, + SERVICE_EFFECT_STOP, + ] diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index c199ee8a9a15d6f704a43b66bb928ee8e54320d6..f91ed761e44fc82b8b19eae49bec17ed026168a9 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -7,6 +7,7 @@ from datetime import timedelta from typing import Any import aiolifx_effects +from aiolifx_themes.themes import Theme, ThemeLibrary import voluptuous as vol from homeassistant.components.light import ( @@ -14,30 +15,31 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, COLOR_GROUP, VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, - preprocess_turn_on_alternatives, ) from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_extract_referenced_entity_ids -from .const import DATA_LIFX_MANAGER, DOMAIN +from .const import ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN from .coordinator import LIFXUpdateCoordinator, Light from .util import convert_8_to_16, find_hsbk SCAN_INTERVAL = timedelta(seconds=10) -SERVICE_EFFECT_PULSE = "effect_pulse" SERVICE_EFFECT_COLORLOOP = "effect_colorloop" +SERVICE_EFFECT_FLAME = "effect_flame" +SERVICE_EFFECT_MORPH = "effect_morph" SERVICE_EFFECT_MOVE = "effect_move" +SERVICE_EFFECT_PULSE = "effect_pulse" SERVICE_EFFECT_STOP = "effect_stop" ATTR_POWER_OFF = "power_off" @@ -48,11 +50,19 @@ ATTR_SPREAD = "spread" ATTR_CHANGE = "change" ATTR_DIRECTION = "direction" ATTR_SPEED = "speed" +ATTR_PALETTE = "palette" +EFFECT_FLAME = "FLAME" +EFFECT_MORPH = "MORPH" EFFECT_MOVE = "MOVE" EFFECT_OFF = "OFF" -EFFECT_MOVE_DEFAULT_SPEED = 3.0 +EFFECT_FLAME_DEFAULT_SPEED = 3 + +EFFECT_MORPH_DEFAULT_SPEED = 3 +EFFECT_MORPH_DEFAULT_THEME = "exciting" + +EFFECT_MOVE_DEFAULT_SPEED = 3 EFFECT_MOVE_DEFAULT_DIRECTION = "right" EFFECT_MOVE_DIRECTION_RIGHT = "right" EFFECT_MOVE_DIRECTION_LEFT = "left" @@ -98,10 +108,10 @@ LIFX_EFFECT_PULSE_SCHEMA = cv.make_entity_service_schema( ) ), ), - vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): vol.All( - vol.Coerce(int), vol.Range(min=1) + vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): vol.All( + vol.Coerce(int), vol.Range(min=1500, max=9000) ), - vol.Exclusive(ATTR_KELVIN, COLOR_GROUP): cv.positive_int, + vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): cv.positive_int, ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Range(min=0.05)), ATTR_CYCLES: vol.All(vol.Coerce(float), vol.Range(min=1)), ATTR_MODE: vol.In(PULSE_MODES), @@ -129,12 +139,44 @@ SERVICES = ( SERVICE_EFFECT_COLORLOOP, ) +LIFX_EFFECT_FLAME_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_SPEED: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=25)), + } +) + +HSBK_SCHEMA = vol.All( + vol.Coerce(tuple), + vol.ExactSequence( + ( + vol.All(vol.Coerce(float), vol.Range(min=0, max=360)), + vol.All(vol.Coerce(float), vol.Range(min=0, max=100)), + vol.All(vol.Coerce(float), vol.Clamp(min=0, max=100)), + vol.All(vol.Coerce(int), vol.Clamp(min=1500, max=9000)), + ) + ), +) + +LIFX_EFFECT_MORPH_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_SPEED: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=25)), + vol.Exclusive(ATTR_THEME, COLOR_GROUP): vol.Optional( + vol.In(ThemeLibrary().themes) + ), + vol.Exclusive(ATTR_PALETTE, COLOR_GROUP): vol.All( + cv.ensure_list, [HSBK_SCHEMA] + ), + } +) LIFX_EFFECT_MOVE_SCHEMA = cv.make_entity_service_schema( { **LIFX_EFFECT_SCHEMA, ATTR_SPEED: vol.All(vol.Coerce(float), vol.Clamp(min=0.1, max=60)), ATTR_DIRECTION: vol.In(EFFECT_MOVE_DIRECTIONS), + ATTR_THEME: vol.Optional(vol.In(ThemeLibrary().themes)), } ) @@ -193,6 +235,20 @@ class LIFXManager: schema=LIFX_EFFECT_COLORLOOP_SCHEMA, ) + self.hass.services.async_register( + DOMAIN, + SERVICE_EFFECT_FLAME, + service_handler, + schema=LIFX_EFFECT_FLAME_SCHEMA, + ) + + self.hass.services.async_register( + DOMAIN, + SERVICE_EFFECT_MORPH, + service_handler, + schema=LIFX_EFFECT_MORPH_SCHEMA, + ) + self.hass.services.async_register( DOMAIN, SERVICE_EFFECT_MOVE, @@ -223,7 +279,43 @@ class LIFXManager: coordinators.append(coordinator) bulbs.append(coordinator.device) - if service == SERVICE_EFFECT_MOVE: + if service == SERVICE_EFFECT_FLAME: + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_FLAME, + speed=kwargs.get(ATTR_SPEED, EFFECT_FLAME_DEFAULT_SPEED), + power_on=kwargs.get(ATTR_POWER_ON, True), + ) + for coordinator in coordinators + ) + ) + + elif service == SERVICE_EFFECT_MORPH: + + theme_name = kwargs.get(ATTR_THEME, "exciting") + palette = kwargs.get(ATTR_PALETTE, None) + + if palette is not None: + theme = Theme() + for hsbk in palette: + theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) + else: + theme = ThemeLibrary().get_theme(theme_name) + + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_MORPH, + speed=kwargs.get(ATTR_SPEED, EFFECT_MORPH_DEFAULT_SPEED), + palette=theme.colors, + power_on=kwargs.get(ATTR_POWER_ON, True), + ) + for coordinator in coordinators + ) + ) + + elif service == SERVICE_EFFECT_MOVE: await asyncio.gather( *( coordinator.async_set_multizone_effect( @@ -232,6 +324,7 @@ class LIFXManager: direction=kwargs.get( ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION ), + theme_name=kwargs.get(ATTR_THEME, None), power_on=kwargs.get(ATTR_POWER_ON, False), ) for coordinator in coordinators @@ -250,7 +343,6 @@ class LIFXManager: await self.effects_conductor.start(effect, bulbs) elif service == SERVICE_EFFECT_COLORLOOP: - preprocess_turn_on_alternatives(self.hass, kwargs) brightness = None if ATTR_BRIGHTNESS in kwargs: @@ -271,9 +363,9 @@ class LIFXManager: await self.effects_conductor.stop(bulbs) for coordinator in coordinators: + await coordinator.async_set_matrix_effect( + effect=EFFECT_OFF, power_on=False + ) await coordinator.async_set_multizone_effect( - effect=EFFECT_OFF, - speed=EFFECT_MOVE_DEFAULT_SPEED, - direction=EFFECT_MOVE_DEFAULT_DIRECTION, - power_on=False, + effect=EFFECT_OFF, power_on=False ) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 45321f22b66a2de00208923609c046e9a806791f..fc5422757b99e2b2be2cf96633cc23040e788efa 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -3,7 +3,11 @@ "name": "LIFX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lifx", - "requirements": ["aiolifx==0.8.5", "aiolifx_effects==0.2.2"], + "requirements": [ + "aiolifx==0.8.6", + "aiolifx_effects==0.3.0", + "aiolifx_themes==0.2.0" + ], "quality_scale": "platinum", "dependencies": ["network"], "homekit": { diff --git a/homeassistant/components/lifx/select.py b/homeassistant/components/lifx/select.py index a1cfb4624d526f78b89b4203f4cb7a4274d0711e..681fe41cc0559dae1c413c21c9622abb7836bc72 100644 --- a/homeassistant/components/lifx/select.py +++ b/homeassistant/components/lifx/select.py @@ -1,24 +1,39 @@ """Select sensor entities for LIFX integration.""" from __future__ import annotations +from aiolifx_themes.themes import ThemeLibrary + from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, INFRARED_BRIGHTNESS, INFRARED_BRIGHTNESS_VALUES_MAP -from .coordinator import LIFXUpdateCoordinator -from .entity import LIFXEntity +from .const import ( + ATTR_THEME, + DOMAIN, + INFRARED_BRIGHTNESS, + INFRARED_BRIGHTNESS_VALUES_MAP, +) +from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator +from .entity import LIFXSensorEntity from .util import lifx_features +THEME_NAMES = [theme_name.lower() for theme_name in ThemeLibrary().themes] + INFRARED_BRIGHTNESS_ENTITY = SelectEntityDescription( key=INFRARED_BRIGHTNESS, name="Infrared brightness", entity_category=EntityCategory.CONFIG, + options=list(INFRARED_BRIGHTNESS_VALUES_MAP.values()), ) -INFRARED_BRIGHTNESS_OPTIONS = list(INFRARED_BRIGHTNESS_VALUES_MAP.values()) +THEME_ENTITY = SelectEntityDescription( + key=ATTR_THEME, + name="Theme", + entity_category=EntityCategory.CONFIG, + options=THEME_NAMES, +) async def async_setup_entry( @@ -27,30 +42,40 @@ async def async_setup_entry( """Set up LIFX from a config entry.""" coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[LIFXSensorEntity] = [] + if lifx_features(coordinator.device)["infrared"]: - async_add_entities( - [ - LIFXInfraredBrightnessSelectEntity( - coordinator, description=INFRARED_BRIGHTNESS_ENTITY - ) - ] + entities.append( + LIFXInfraredBrightnessSelectEntity( + coordinator.sensor_coordinator, description=INFRARED_BRIGHTNESS_ENTITY + ) + ) + + if lifx_features(coordinator.device)["multizone"] is True: + entities.append( + LIFXThemeSelectEntity( + coordinator.sensor_coordinator, description=THEME_ENTITY + ) ) + async_add_entities(entities) -class LIFXInfraredBrightnessSelectEntity(LIFXEntity, SelectEntity): + +class LIFXInfraredBrightnessSelectEntity(LIFXSensorEntity, SelectEntity): """LIFX Nightvision infrared brightness configuration entity.""" _attr_has_entity_name = True - _attr_options = INFRARED_BRIGHTNESS_OPTIONS def __init__( - self, coordinator: LIFXUpdateCoordinator, description: SelectEntityDescription + self, + coordinator: LIFXSensorUpdateCoordinator, + description: SelectEntityDescription, ) -> None: """Initialise the IR brightness config entity.""" super().__init__(coordinator) self.entity_description = description self._attr_name = description.name - self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" self._attr_current_option = coordinator.current_infrared_brightness @callback @@ -67,3 +92,37 @@ class LIFXInfraredBrightnessSelectEntity(LIFXEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Update the infrared brightness value.""" await self.coordinator.async_set_infrared_brightness(option) + + +class LIFXThemeSelectEntity(LIFXSensorEntity, SelectEntity): + """Theme entity for LIFX multizone devices.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LIFXSensorUpdateCoordinator, + description: SelectEntityDescription, + ) -> None: + """Initialise the theme selection entity.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_name = description.name + self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_current_option = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update attrs from coordinator data.""" + self._attr_current_option = self.coordinator.last_used_theme + + async def async_select_option(self, option: str) -> None: + """Paint the selected theme onto the device.""" + await self.coordinator.async_apply_theme(option.lower()) diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..bff04f0a8074b876a1132f83698fd546d64c1739 --- /dev/null +++ b/homeassistant/components/lifx/sensor.py @@ -0,0 +1,74 @@ +"""Sensors for LIFX lights.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_RSSI, DOMAIN +from .coordinator import LIFXSensorUpdateCoordinator, LIFXUpdateCoordinator +from .entity import LIFXSensorEntity + +SCAN_INTERVAL = timedelta(seconds=30) + +RSSI_SENSOR = SensorEntityDescription( + key=ATTR_RSSI, + name="RSSI", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up LIFX sensor from config entry.""" + coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([LIFXRssiSensor(coordinator.sensor_coordinator, RSSI_SENSOR)]) + + +class LIFXRssiSensor(LIFXSensorEntity, SensorEntity): + """LIFX RSSI sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LIFXSensorUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialise the RSSI sensor.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_name = description.name + self._attr_unique_id = f"{coordinator.parent.serial_number}_{description.key}" + self._attr_native_unit_of_measurement = coordinator.rssi_uom + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Handle coordinator updates.""" + self._attr_native_value = self.coordinator.rssi + + @callback + async def async_added_to_hass(self) -> None: + """Enable RSSI updates.""" + self.async_on_remove(self.coordinator.async_enable_rssi_updates()) + return await super().async_added_to_hass() diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml index fc2e522dcd49cc8e0f0cb0e4941b280d10ca4289..976d4ff56238833e25d37f2f60ce62eeffa43ca3 100644 --- a/homeassistant/components/lifx/services.yaml +++ b/homeassistant/components/lifx/services.yaml @@ -183,6 +183,7 @@ effect_move: name: Speed description: How long in seconds for the effect to move across the length of the light. default: 3.0 + example: 3.0 selector: number: min: 0.1 @@ -193,19 +194,137 @@ effect_move: name: Direction description: Direction the effect will move across the device. default: right + example: right selector: select: mode: dropdown options: - right - left + theme: + name: Theme + description: (Optional) set one of the predefined themes onto the device before starting the effect. + example: exciting + default: exciting + selector: + select: + mode: dropdown + options: + - "autumn" + - "blissful" + - "cheerful" + - "dream" + - "energizing" + - "epic" + - "exciting" + - "focusing" + - "halloween" + - "hanukkah" + - "holly" + - "independence_day" + - "intense" + - "mellow" + - "peaceful" + - "powerful" + - "relaxing" + - "santa" + - "serene" + - "soothing" + - "sports" + - "spring" + - "tranquil" + - "warming" + power_on: + name: Power on + description: Powered off lights will be turned on before starting the effect. + default: true + selector: + boolean: +effect_flame: + name: Flame effect + description: Start the firmware-based Flame effect on LIFX Tiles or Candle. + target: + entity: + integration: lifx + domain: light + fields: + speed: + name: Speed + description: How fast the flames will move. + default: 3 + selector: + number: + min: 1 + max: 25 + step: 1 + unit_of_measurement: seconds + power_on: + name: Power on + description: Powered off lights will be turned on before starting the effect. + default: true + selector: + boolean: +effect_morph: + name: Morph effect + description: Start the firmware-based Morph effect on LIFX Tiles on Candle. + target: + entity: + integration: lifx + domain: light + fields: + speed: + name: Speed + description: How fast the colors will move. + default: 3 + selector: + number: + min: 1 + max: 25 + step: 1 + unit_of_measurement: seconds + palette: + name: Palette + description: List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-900) values to use for this effect. Overrides the theme attribute. + example: + - "[[0, 100, 100, 3500], [60, 100, 100, 3500]]" + selector: + object: + theme: + name: Theme + description: Predefined color theme to use for the effect. Overridden by the palette attribute. + selector: + select: + options: + - "autumn" + - "blissful" + - "cheerful" + - "dream" + - "energizing" + - "epic" + - "exciting" + - "focusing" + - "halloween" + - "hanukkah" + - "holly" + - "independence_day" + - "intense" + - "mellow" + - "peaceful" + - "powerful" + - "relaxing" + - "santa" + - "serene" + - "soothing" + - "sports" + - "spring" + - "tranquil" + - "warming" power_on: name: Power on description: Powered off lights will be turned on before starting the effect. default: true selector: boolean: - effect_stop: name: Stop effect description: Stop a running effect. diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 2136ab5f63b41e6443927e7989659470ce7f85a7..135e1a7e8e917eb33cdf141a4beb28f267e1dc41 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -14,18 +14,17 @@ from awesomeversion import AwesomeVersion from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_RGB_COLOR, ATTR_XY_COLOR, - preprocess_turn_on_alternatives, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr import homeassistant.util.color as color_util -from .const import _LOGGER, DOMAIN, INFRARED_BRIGHTNESS_VALUES_MAP, OVERALL_TIMEOUT +from .const import DOMAIN, INFRARED_BRIGHTNESS_VALUES_MAP, OVERALL_TIMEOUT FIX_MAC_FW = AwesomeVersion("3.70") @@ -81,8 +80,6 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] | """ hue, saturation, brightness, kelvin = [None] * 4 - preprocess_turn_on_alternatives(hass, kwargs) - if ATTR_HS_COLOR in kwargs: hue, saturation = kwargs[ATTR_HS_COLOR] elif ATTR_RGB_COLOR in kwargs: @@ -96,10 +93,8 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] | saturation = int(saturation / 100 * 65535) kelvin = 3500 - if ATTR_COLOR_TEMP in kwargs: - kelvin = int( - color_util.color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) - ) + if ATTR_COLOR_TEMP_KELVIN in kwargs: + kelvin = kwargs.pop(ATTR_COLOR_TEMP_KELVIN) saturation = 0 if ATTR_BRIGHTNESS in kwargs: @@ -159,8 +154,6 @@ async def async_execute_lifx(method: Callable) -> Message: # us by async_timeout when we hit the OVERALL_TIMEOUT future.set_result(message) - _LOGGER.debug("Sending LIFX command: %s", method) - method(callb=_callback) result = None diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 7d34c607b1f18f9af3a387630871d53553b438dd..5bf72b7267bd289e1470c06076088e4bd3092801 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -196,10 +196,13 @@ ATTR_RGBW_COLOR = "rgbw_color" ATTR_RGBWW_COLOR = "rgbww_color" ATTR_XY_COLOR = "xy_color" ATTR_HS_COLOR = "hs_color" -ATTR_COLOR_TEMP = "color_temp" -ATTR_KELVIN = "kelvin" -ATTR_MIN_MIREDS = "min_mireds" -ATTR_MAX_MIREDS = "max_mireds" +ATTR_COLOR_TEMP = "color_temp" # Deprecated in HA Core 2022.11 +ATTR_KELVIN = "kelvin" # Deprecated in HA Core 2022.11 +ATTR_MIN_MIREDS = "min_mireds" # Deprecated in HA Core 2022.11 +ATTR_MAX_MIREDS = "max_mireds" # Deprecated in HA Core 2022.11 +ATTR_COLOR_TEMP_KELVIN = "color_temp_kelvin" +ATTR_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin" +ATTR_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin" ATTR_COLOR_NAME = "color_name" ATTR_WHITE = "white" @@ -249,6 +252,7 @@ LIGHT_TURN_ON_SCHEMA = { vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): vol.All( vol.Coerce(int), vol.Range(min=1) ), + vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): cv.positive_int, vol.Exclusive(ATTR_KELVIN, COLOR_GROUP): cv.positive_int, vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): vol.All( vol.Coerce(tuple), @@ -309,9 +313,20 @@ def preprocess_turn_on_alternatives( _LOGGER.warning("Got unknown color %s, falling back to white", color_name) params[ATTR_RGB_COLOR] = (255, 255, 255) + if (mired := params.pop(ATTR_COLOR_TEMP, None)) is not None: + kelvin = color_util.color_temperature_mired_to_kelvin(mired) + params[ATTR_COLOR_TEMP] = int(mired) + params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin) + if (kelvin := params.pop(ATTR_KELVIN, None)) is not None: mired = color_util.color_temperature_kelvin_to_mired(kelvin) params[ATTR_COLOR_TEMP] = int(mired) + params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin) + + if (kelvin := params.pop(ATTR_COLOR_TEMP_KELVIN, None)) is not None: + mired = color_util.color_temperature_kelvin_to_mired(kelvin) + params[ATTR_COLOR_TEMP] = int(mired) + params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin) brightness_pct = params.pop(ATTR_BRIGHTNESS_PCT, None) if brightness_pct is not None: @@ -350,6 +365,7 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st params.pop(ATTR_BRIGHTNESS, None) if ColorMode.COLOR_TEMP not in supported_color_modes: params.pop(ATTR_COLOR_TEMP, None) + params.pop(ATTR_COLOR_TEMP_KELVIN, None) if ColorMode.HS not in supported_color_modes: params.pop(ATTR_HS_COLOR, None) if ColorMode.RGB not in supported_color_modes: @@ -424,22 +440,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: supported_color_modes = light.supported_color_modes # If a color temperature is specified, emulate it if not supported by the light - if ATTR_COLOR_TEMP in params: + if ATTR_COLOR_TEMP_KELVIN in params: if ( supported_color_modes and ColorMode.COLOR_TEMP not in supported_color_modes and ColorMode.RGBWW in supported_color_modes ): - color_temp = params.pop(ATTR_COLOR_TEMP) + params.pop(ATTR_COLOR_TEMP) + color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) brightness = params.get(ATTR_BRIGHTNESS, light.brightness) params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww( - color_temp, brightness, light.min_mireds, light.max_mireds + color_temp, + brightness, + light.min_color_temp_kelvin, + light.max_color_temp_kelvin, ) elif ColorMode.COLOR_TEMP not in legacy_supported_color_modes: - color_temp = params.pop(ATTR_COLOR_TEMP) + params.pop(ATTR_COLOR_TEMP) + color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) if color_supported(legacy_supported_color_modes): - temp_k = color_util.color_temperature_mired_to_kelvin(color_temp) - params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(temp_k) + params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs( + color_temp + ) # If a color is specified, convert to the color space supported by the light # Backwards compatibility: Fall back to hs color if light.supported_color_modes @@ -457,7 +479,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif (rgbww_color := params.pop(ATTR_RGBWW_COLOR, None)) is not None: # https://github.com/python/mypy/issues/13673 rgb_color = color_util.color_rgbww_to_rgb( # type: ignore[call-arg] - *rgbww_color, light.min_mireds, light.max_mireds + *rgbww_color, + light.min_color_temp_kelvin, + light.max_color_temp_kelvin, ) params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes: @@ -470,7 +494,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif ColorMode.RGBWW in supported_color_modes: rgb_color = color_util.color_hs_to_RGB(*hs_color) params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( - *rgb_color, light.min_mireds, light.max_mireds + *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) @@ -481,7 +505,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif ColorMode.RGBWW in supported_color_modes: # https://github.com/python/mypy/issues/13673 params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( # type: ignore[call-arg] - *rgb_color, light.min_mireds, light.max_mireds + *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) elif ColorMode.HS in supported_color_modes: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) @@ -499,7 +523,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif ColorMode.RGBWW in supported_color_modes: rgb_color = color_util.color_xy_to_RGB(*xy_color) params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( - *rgb_color, light.min_mireds, light.max_mireds + *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: rgbw_color = params.pop(ATTR_RGBW_COLOR) @@ -508,7 +532,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_RGB_COLOR] = rgb_color elif ColorMode.RGBWW in supported_color_modes: params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( - *rgb_color, light.min_mireds, light.max_mireds + *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) elif ColorMode.HS in supported_color_modes: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) @@ -520,7 +544,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: assert (rgbww_color := params.pop(ATTR_RGBWW_COLOR)) is not None # https://github.com/python/mypy/issues/13673 rgb_color = color_util.color_rgbww_to_rgb( # type: ignore[call-arg] - *rgbww_color, light.min_mireds, light.max_mireds + *rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) if ColorMode.RGB in supported_color_modes: params[ATTR_RGB_COLOR] = rgb_color @@ -755,11 +779,16 @@ class LightEntity(ToggleEntity): _attr_brightness: int | None = None _attr_color_mode: ColorMode | str | None = None _attr_color_temp: int | None = None + _attr_color_temp_kelvin: int | None = None _attr_effect_list: list[str] | None = None _attr_effect: str | None = None _attr_hs_color: tuple[float, float] | None = None - _attr_max_mireds: int = 500 - _attr_min_mireds: int = 153 + # Default to the Philips Hue value that HA has always assumed + # https://developers.meethue.com/documentation/core-concepts + _attr_max_color_temp_kelvin: int | None = None + _attr_min_color_temp_kelvin: int | None = None + _attr_max_mireds: int = 500 # 2000 K + _attr_min_mireds: int = 153 # 6500 K _attr_rgb_color: tuple[int, int, int] | None = None _attr_rgbw_color: tuple[int, int, int, int] | None = None _attr_rgbww_color: tuple[int, int, int, int, int] | None = None @@ -787,7 +816,7 @@ class LightEntity(ToggleEntity): if ColorMode.HS in supported and self.hs_color is not None: return ColorMode.HS - if ColorMode.COLOR_TEMP in supported and self.color_temp is not None: + if ColorMode.COLOR_TEMP in supported and self.color_temp_kelvin is not None: return ColorMode.COLOR_TEMP if ColorMode.BRIGHTNESS in supported and self.brightness is not None: return ColorMode.BRIGHTNESS @@ -833,20 +862,37 @@ class LightEntity(ToggleEntity): """Return the CT color value in mireds.""" return self._attr_color_temp + @property + def color_temp_kelvin(self) -> int | None: + """Return the CT color value in Kelvin.""" + if self._attr_color_temp_kelvin is None and self.color_temp: + return color_util.color_temperature_mired_to_kelvin(self.color_temp) + return self._attr_color_temp_kelvin + @property def min_mireds(self) -> int: """Return the coldest color_temp that this light supports.""" - # Default to the Philips Hue value that HA has always assumed - # https://developers.meethue.com/documentation/core-concepts return self._attr_min_mireds @property def max_mireds(self) -> int: """Return the warmest color_temp that this light supports.""" - # Default to the Philips Hue value that HA has always assumed - # https://developers.meethue.com/documentation/core-concepts return self._attr_max_mireds + @property + def min_color_temp_kelvin(self) -> int: + """Return the warmest color_temp_kelvin that this light supports.""" + if self._attr_min_color_temp_kelvin is None: + return color_util.color_temperature_mired_to_kelvin(self.max_mireds) + return self._attr_min_color_temp_kelvin + + @property + def max_color_temp_kelvin(self) -> int: + """Return the coldest color_temp_kelvin that this light supports.""" + if self._attr_max_color_temp_kelvin is None: + return color_util.color_temperature_mired_to_kelvin(self.min_mireds) + return self._attr_max_color_temp_kelvin + @property def effect_list(self) -> list[str] | None: """Return the list of supported effects.""" @@ -865,9 +911,20 @@ class LightEntity(ToggleEntity): supported_color_modes = self._light_internal_supported_color_modes if ColorMode.COLOR_TEMP in supported_color_modes: - data[ATTR_MIN_MIREDS] = self.min_mireds - data[ATTR_MAX_MIREDS] = self.max_mireds - + data[ATTR_MIN_COLOR_TEMP_KELVIN] = self.min_color_temp_kelvin + data[ATTR_MAX_COLOR_TEMP_KELVIN] = self.max_color_temp_kelvin + if not self.max_color_temp_kelvin: + data[ATTR_MIN_MIREDS] = None + else: + data[ATTR_MIN_MIREDS] = color_util.color_temperature_kelvin_to_mired( + self.max_color_temp_kelvin + ) + if not self.min_color_temp_kelvin: + data[ATTR_MAX_MIREDS] = None + else: + data[ATTR_MAX_MIREDS] = color_util.color_temperature_kelvin_to_mired( + self.min_color_temp_kelvin + ) if supported_features & LightEntityFeature.EFFECT: data[ATTR_EFFECT_LIST] = self.effect_list @@ -904,16 +961,14 @@ class LightEntity(ToggleEntity): elif color_mode == ColorMode.RGBWW and self.rgbww_color: rgbww_color = self.rgbww_color rgb_color = color_util.color_rgbww_to_rgb( - *rgbww_color, self.min_mireds, self.max_mireds + *rgbww_color, self.min_color_temp_kelvin, self.max_color_temp_kelvin ) data[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) data[ATTR_RGB_COLOR] = tuple(int(x) for x in rgb_color[0:3]) data[ATTR_RGBWW_COLOR] = tuple(int(x) for x in rgbww_color[0:5]) data[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif color_mode == ColorMode.COLOR_TEMP and self.color_temp: - hs_color = color_util.color_temperature_to_hs( - color_util.color_temperature_mired_to_kelvin(self.color_temp) - ) + elif color_mode == ColorMode.COLOR_TEMP and self.color_temp_kelvin: + hs_color = color_util.color_temperature_to_hs(self.color_temp_kelvin) data[ATTR_HS_COLOR] = (round(hs_color[0], 3), round(hs_color[1], 3)) data[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color) data[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) @@ -949,7 +1004,13 @@ class LightEntity(ToggleEntity): data[ATTR_BRIGHTNESS] = self.brightness if color_mode == ColorMode.COLOR_TEMP: - data[ATTR_COLOR_TEMP] = self.color_temp + data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin + if not self.color_temp_kelvin: + data[ATTR_COLOR_TEMP] = None + else: + data[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + self.color_temp_kelvin + ) if color_mode in COLOR_MODES_COLOR or color_mode == ColorMode.COLOR_TEMP: data.update(self._light_internal_convert_color(color_mode)) @@ -957,7 +1018,13 @@ class LightEntity(ToggleEntity): if supported_features & SUPPORT_COLOR_TEMP and not self.supported_color_modes: # Backwards compatibility # Add warning in 2021.6, remove in 2021.10 - data[ATTR_COLOR_TEMP] = self.color_temp + data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin + if not self.color_temp_kelvin: + data[ATTR_COLOR_TEMP] = None + else: + data[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + self.color_temp_kelvin + ) if supported_features & LightEntityFeature.EFFECT: data[ATTR_EFFECT] = self.effect diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index ed81398367463050fda2ba6daa970ad85fdd99b7..0965670e569f470a8edd92f7289e00a10612d1a8 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -3,7 +3,7 @@ "name": "Litter-Robot", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litterrobot", - "requirements": ["pylitterbot==2022.9.6"], + "requirements": ["pylitterbot==2022.10.2"], "codeowners": ["@natekspencer", "@tkdrob"], "dhcp": [{ "hostname": "litter-robot4" }], "iot_class": "cloud_push", diff --git a/homeassistant/components/litterrobot/translations/ca.json b/homeassistant/components/litterrobot/translations/ca.json index 10506ab95d6aae066657d3410fcd9de37bdcf306..059e0ea62366a3edc8055e6a44f77334419e3b64 100644 --- a/homeassistant/components/litterrobot/translations/ca.json +++ b/homeassistant/components/litterrobot/translations/ca.json @@ -24,5 +24,11 @@ } } } + }, + "issues": { + "migrated_attributes": { + "description": "Els atributs de l'entitat aspirador ara estan disponibles com a sensors de diagn\u00f2stic. \n\nActualitza les automatitzacions o scripts que tinguis que utilitzin aquests atributs.", + "title": "Els atributs de Litter-Robot s\u00f3n ara els seus propis sensors" + } } } \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/el.json b/homeassistant/components/litterrobot/translations/el.json index d5f7cabb2dff893b5088229c7ed07ed56f5720b9..f965d1be9ca6bf15bb3fa98c2a967bb1f0959cb7 100644 --- a/homeassistant/components/litterrobot/translations/el.json +++ b/homeassistant/components/litterrobot/translations/el.json @@ -24,5 +24,11 @@ } } } + }, + "issues": { + "migrated_attributes": { + "description": "\u03a4\u03b1 \u03c7\u03b1\u03c1\u03b1\u03ba\u03c4\u03b7\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03ac \u03c4\u03b7\u03c2 \u03bf\u03bd\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03c4\u03b7\u03c2 \u03c3\u03ba\u03bf\u03cd\u03c0\u03b1\u03c2 \u03b5\u03af\u03bd\u03b1\u03b9 \u03c4\u03ce\u03c1\u03b1 \u03b4\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03b1 \u03c9\u03c2 \u03b4\u03b9\u03b1\u03b3\u03bd\u03c9\u03c3\u03c4\u03b9\u03ba\u03bf\u03af \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b5\u03c2.\n\n\u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03c0\u03c1\u03bf\u03c3\u03b1\u03c1\u03bc\u03cc\u03c3\u03c4\u03b5 \u03c4\u03c5\u03c7\u03cc\u03bd \u03b1\u03c5\u03c4\u03bf\u03bc\u03b1\u03c4\u03b9\u03c3\u03bc\u03bf\u03cd\u03c2 \u03ae \u03c3\u03b5\u03bd\u03ac\u03c1\u03b9\u03b1 \u03c0\u03bf\u03c5 \u03bc\u03c0\u03bf\u03c1\u03b5\u03af \u03bd\u03b1 \u03ad\u03c7\u03b5\u03c4\u03b5 \u03ba\u03b1\u03b9 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03bf\u03cd\u03bd \u03b1\u03c5\u03c4\u03ac \u03c4\u03b1 \u03c7\u03b1\u03c1\u03b1\u03ba\u03c4\u03b7\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03ac.", + "title": "\u03a4\u03b1 \u03c7\u03b1\u03c1\u03b1\u03ba\u03c4\u03b7\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03ac Litter-Robot \u03b5\u03af\u03bd\u03b1\u03b9 \u03c0\u03bb\u03ad\u03bf\u03bd \u03bf\u03b9 \u03b4\u03b9\u03ba\u03bf\u03af \u03c4\u03bf\u03c5\u03c2 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b5\u03c2" + } } } \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/et.json b/homeassistant/components/litterrobot/translations/et.json index 8bbd26ee4c4872fac58cdf3d0547ed01d74c7d73..b271a1195d914ce6b867eec4065b947fcfc62ec2 100644 --- a/homeassistant/components/litterrobot/translations/et.json +++ b/homeassistant/components/litterrobot/translations/et.json @@ -24,5 +24,11 @@ } } } + }, + "issues": { + "migrated_attributes": { + "description": "Vaakumseadme atribuudid on n\u00fc\u00fcd saadaval diagnostiliste anduritena.\n\nPalun kohandage k\u00f5iki automatiseerimisi v\u00f5i skripte, mis neid atribuute kasutavad.", + "title": "Litter-Roboti atribuudid on n\u00fc\u00fcd oma andurid" + } } } \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/he.json b/homeassistant/components/litterrobot/translations/he.json index 454b7e1ae510eb27cbad7f47f6c30e2ffe2a212e..d6636c6f86540bf35d11e401ea03222aef6991e0 100644 --- a/homeassistant/components/litterrobot/translations/he.json +++ b/homeassistant/components/litterrobot/translations/he.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", @@ -9,6 +10,12 @@ "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", diff --git a/homeassistant/components/litterrobot/translations/id.json b/homeassistant/components/litterrobot/translations/id.json index 73e19f1d4398d03a59e618a91d008875bea2300c..b8d3d58a8dc6925109b5d3fb711344eb17b7f2ec 100644 --- a/homeassistant/components/litterrobot/translations/id.json +++ b/homeassistant/components/litterrobot/translations/id.json @@ -24,5 +24,11 @@ } } } + }, + "issues": { + "migrated_attributes": { + "description": "Atribut entitas vakum sekarang tersedia sebagai sensor diagnostik.\n\nSesuaikan semua otomasi atau skrip yang mungkin Anda miliki yang menggunakan atribut ini.", + "title": "Atribut Litter-Robot sekarang menjadi sensor tersendiri" + } } } \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/it.json b/homeassistant/components/litterrobot/translations/it.json index 8b8ea9c03bb7ab5ecb9fd5e8798c4da2d4d53a7a..7e09efc0610439289989c58e68f368da1cd68c04 100644 --- a/homeassistant/components/litterrobot/translations/it.json +++ b/homeassistant/components/litterrobot/translations/it.json @@ -24,5 +24,11 @@ } } } + }, + "issues": { + "migrated_attributes": { + "description": "Gli attributi dell'entit\u00e0 aspirapolvere sono ora disponibili come sensori diagnostici. \n\nModifica eventuali automazioni o script che potresti avere che utilizzano questi attributi.", + "title": "Gli attributi Litter-Robot sono ora sensori propri" + } } } \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/nb.json b/homeassistant/components/litterrobot/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/litterrobot/translations/nb.json +++ b/homeassistant/components/litterrobot/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/litterrobot/translations/no.json b/homeassistant/components/litterrobot/translations/no.json index 6268bdf0ff7d50976156c463fadb1e08db7c42c3..92853ca057ca1809aa100441f66d357579985b80 100644 --- a/homeassistant/components/litterrobot/translations/no.json +++ b/homeassistant/components/litterrobot/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/litterrobot/translations/pl.json b/homeassistant/components/litterrobot/translations/pl.json index 306acce874373ac9bac668e8fc5ccd71769c1aca..aaad705c2a732628806e105adc6578f0c31b56bd 100644 --- a/homeassistant/components/litterrobot/translations/pl.json +++ b/homeassistant/components/litterrobot/translations/pl.json @@ -24,5 +24,11 @@ } } } + }, + "issues": { + "migrated_attributes": { + "description": "Atrybuty encji s\u0105 teraz dost\u0119pne jako sensory diagnostyczne. \n\nDostosuj wszelkie automatyzacje lub skrypty korzystaj\u0105ce z tych atrybut\u00f3w.", + "title": "Atrybuty Litter-Robot s\u0105 teraz ich w\u0142asnymi sensorami" + } } } \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/pt-BR.json b/homeassistant/components/litterrobot/translations/pt-BR.json index 9b204c74f0777e1a8efde32a3e8e2d3f004ade93..dfeb0a9018fbcac096758fb42ac5da533caa1cd6 100644 --- a/homeassistant/components/litterrobot/translations/pt-BR.json +++ b/homeassistant/components/litterrobot/translations/pt-BR.json @@ -24,5 +24,11 @@ } } } + }, + "issues": { + "migrated_attributes": { + "description": "Os atributos da entidade de v\u00e1cuo agora est\u00e3o dispon\u00edveis como sensores de diagn\u00f3stico. \n\n Ajuste quaisquer automa\u00e7\u00f5es ou scripts que voc\u00ea possa ter que usem esses atributos.", + "title": "Os atributos do Litter-Robot agora s\u00e3o seus pr\u00f3prios sensores" + } } } \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/sensor.sv.json b/homeassistant/components/litterrobot/translations/sensor.sv.json index c54c705b8c4e4d52c2a7d604eec228e9c805995f..9509408a94eec1cd6178a9d384cd968d5661693a 100644 --- a/homeassistant/components/litterrobot/translations/sensor.sv.json +++ b/homeassistant/components/litterrobot/translations/sensor.sv.json @@ -4,6 +4,7 @@ "br": "Huven \u00e4r borttagen", "ccc": "Reningscykel klar", "ccp": "Reng\u00f6ringscykel p\u00e5g\u00e5r", + "cd": "Katt uppt\u00e4ckt", "csf": "Kattsensor fel", "csi": "Kattsensor avbruten", "cst": "Kattsensor timing", @@ -19,6 +20,8 @@ "otf": "Fel vid f\u00f6r h\u00f6gt vridmoment", "p": "Pausad", "pd": "Pinch Detect", + "pwrd": "St\u00e4nger av", + "pwru": "Startar upp", "rdy": "Redo", "scf": "Fel p\u00e5 kattsensorn vid uppstart", "sdf": "L\u00e5dan full vid uppstart", diff --git a/homeassistant/components/litterrobot/translations/sensor.tr.json b/homeassistant/components/litterrobot/translations/sensor.tr.json index 2db5e574f7e370284862d6bae24f0bb9153027e0..e9848e96501c885dc878e68c5fe904c4cfcf37b1 100644 --- a/homeassistant/components/litterrobot/translations/sensor.tr.json +++ b/homeassistant/components/litterrobot/translations/sensor.tr.json @@ -4,6 +4,7 @@ "br": "Kapak \u00c7\u0131kar\u0131ld\u0131", "ccc": "Temizleme Tamamland\u0131", "ccp": "Temizleme Devam Ediyor", + "cd": "Kedi Tespit Edildi", "csf": "Kedi Sens\u00f6r\u00fc Hatas\u0131", "csi": "Kedi Sens\u00f6r\u00fc Kesildi", "cst": "Kedi Sens\u00f6r Zamanlamas\u0131", @@ -19,6 +20,8 @@ "otf": "A\u015f\u0131r\u0131 Tork Ar\u0131zas\u0131", "p": "Durduruldu", "pd": "S\u0131k\u0131\u015fma Alg\u0131lama", + "pwrd": "G\u00fcc\u00fc Kapatma", + "pwru": "G\u00fcc\u00fc A\u00e7ma", "rdy": "Haz\u0131r", "scf": "Ba\u015flang\u0131\u00e7ta Cat Sens\u00f6r\u00fc Hatas\u0131", "sdf": "Ba\u015flang\u0131\u00e7ta Hazne Dolu", diff --git a/homeassistant/components/litterrobot/translations/sv.json b/homeassistant/components/litterrobot/translations/sv.json index e8919b760d80de641a3d1c20b26a29cec1eeaf5a..fec9180f066cdfc264b16f89e4d6dd5753a5c33c 100644 --- a/homeassistant/components/litterrobot/translations/sv.json +++ b/homeassistant/components/litterrobot/translations/sv.json @@ -24,5 +24,11 @@ } } } + }, + "issues": { + "migrated_attributes": { + "description": "Vakuuminneh\u00e5llet \u00e4r nu tillg\u00e4ngligt som diagnostiska sensorer.\n\nV\u00e4nligen justera eventuella automatiseringar eller skript som anv\u00e4nder dessa attribut.", + "title": "Litter-Robots attribut \u00e4r nu deras egna sensorer" + } } } \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/tr.json b/homeassistant/components/litterrobot/translations/tr.json index 193413280eb83f094da8506a591a5c761ff34406..70b19443055e638aa0afa045d8c7f5cd60fcaf0e 100644 --- a/homeassistant/components/litterrobot/translations/tr.json +++ b/homeassistant/components/litterrobot/translations/tr.json @@ -24,5 +24,11 @@ } } } + }, + "issues": { + "migrated_attributes": { + "description": "Vakum varl\u0131k \u00f6znitelikleri art\u0131k tan\u0131 sens\u00f6rleri olarak mevcuttur. \n\n L\u00fctfen bu \u00f6znitelikleri kullanan t\u00fcm otomasyonlar\u0131 veya komut dosyalar\u0131n\u0131 ayarlay\u0131n.", + "title": "Litter-Robot \u00f6znitelikleri art\u0131k kendi sens\u00f6rleridir" + } } } \ No newline at end of file diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index d28666963c874b1e8146b0c2fea94142284212ee..8d0dd49bff8bffb57406ee6de45321b4991916df 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -260,7 +260,7 @@ async def _async_events_consumer( ) @websocket_api.async_response async def ws_event_stream( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle logbook stream events websocket command.""" start_time_str = msg["start_time"] @@ -451,7 +451,7 @@ def _ws_formatted_get_events( ) @websocket_api.async_response async def ws_get_events( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle logbook get events websocket command.""" start_time_str = msg["start_time"] diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index 2b8bec957faaa009da1c5622daa2c7fc06d2cc51..5fc999d7d11e9939ceec0074b6c193901bd08aad 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -23,8 +23,6 @@ LOGSEVERITY = { "NOTSET": 0, } -DEFAULT_LOGSEVERITY = "DEBUG" - LOGGER_DEFAULT = "default" LOGGER_LOGS = "logs" LOGGER_FILTERS = "filters" @@ -68,13 +66,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _set_log_level(logging.getLogger(key), value) # Set default log severity - set_default_log_level(config[DOMAIN].get(LOGGER_DEFAULT, DEFAULT_LOGSEVERITY)) + logger_config = config.get(DOMAIN, {}) + + if LOGGER_DEFAULT in logger_config: + set_default_log_level(logger_config[LOGGER_DEFAULT]) - if LOGGER_LOGS in config[DOMAIN]: + if LOGGER_LOGS in logger_config: set_log_levels(config[DOMAIN][LOGGER_LOGS]) - if LOGGER_FILTERS in config[DOMAIN]: - for key, value in config[DOMAIN][LOGGER_FILTERS].items(): + if LOGGER_FILTERS in logger_config: + for key, value in logger_config[LOGGER_FILTERS].items(): logger = logging.getLogger(key) _add_log_filter(logger, value) diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 733e49ca0bf5c25df22f2889cd609c52bcf45375..4a8b36a3d555c7b7d423df04aeb6dd668bf8dfb5 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -8,7 +8,6 @@ from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, @@ -62,6 +61,7 @@ async def async_setup_entry( class LogiCam(Camera): """An implementation of a Logi Circle camera.""" + _attr_attribution = ATTRIBUTION _attr_should_poll = True # Cameras default to False _attr_supported_features = CameraEntityFeature.ON_OFF @@ -141,7 +141,6 @@ class LogiCam(Camera): def extra_state_attributes(self): """Return the state attributes.""" state = { - ATTR_ATTRIBUTION: ATTRIBUTION, "battery_saving_mode": ( STATE_ON if self._camera.battery_saving else STATE_OFF ), diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py index baf6d93391698842979f2a7523ab227845a63cbe..b31a7bda2b2a834c38498bb7016058a357cd563c 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -7,7 +7,6 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, CONF_MONITORED_CONDITIONS, CONF_SENSORS, @@ -58,6 +57,8 @@ async def async_setup_entry( class LogiSensor(SensorEntity): """A sensor implementation for a Logi Circle camera.""" + _attr_attribution = ATTRIBUTION + def __init__(self, camera, time_zone, description: SensorEntityDescription): """Initialize a sensor for Logi Circle camera.""" self.entity_description = description @@ -82,7 +83,6 @@ class LogiSensor(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" state = { - ATTR_ATTRIBUTION: ATTRIBUTION, "battery_saving_mode": ( STATE_ON if self._camera.battery_saving else STATE_OFF ), diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index b111fb8be6c474405e449d2c32d9cc341a2cf4af..2cad8e9a10939b063dfc3ef11ffbf67a4eb94865 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -9,7 +9,6 @@ from london_tube_status import TubeData 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.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -25,8 +24,6 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "london_underground" -ATTRIBUTION = "Powered by TfL Open Data" - CONF_LINE = "line" ICON = "mdi:subway" @@ -102,11 +99,12 @@ class LondonTubeCoordinator(DataUpdateCoordinator): class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Sensor that reads the status of a line from Tube Data.""" + _attr_attribution = "Powered by TfL Open Data" + def __init__(self, coordinator, name): """Initialize the London Underground sensor.""" super().__init__(coordinator) self._name = name - self.attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} @property def name(self): @@ -126,5 +124,4 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): @property def extra_state_attributes(self): """Return other details about the sensor state.""" - self.attrs["Description"] = self.coordinator.data[self.name]["Description"] - return self.attrs + return {"Description": self.coordinator.data[self.name]["Description"]} diff --git a/homeassistant/components/lookin/translations/nb.json b/homeassistant/components/lookin/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/lookin/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index 66ec7f22b09d949f6e0261bcfed7ff44315577f0..423ba3117eaea9d115973a27d43955de29f097cf 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -1,23 +1,31 @@ """Websocket API for Lovelace.""" +from __future__ import annotations + from functools import wraps +from typing import Any import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound +from .dashboard import LovelaceStorage def _handle_errors(func): """Handle error with WebSocket calls.""" @wraps(func) - async def send_with_error_handling(hass, connection, msg): + async def send_with_error_handling( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: url_path = msg.get(CONF_URL_PATH) - config = hass.data[DOMAIN]["dashboards"].get(url_path) + config: LovelaceStorage | None = hass.data[DOMAIN]["dashboards"].get(url_path) if config is None: connection.send_error( @@ -44,7 +52,11 @@ def _handle_errors(func): @websocket_api.websocket_command({"type": "lovelace/resources"}) @websocket_api.async_response -async def websocket_lovelace_resources(hass, connection, msg): +async def websocket_lovelace_resources( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Send Lovelace UI resources over WebSocket configuration.""" resources = hass.data[DOMAIN]["resources"] @@ -64,7 +76,12 @@ async def websocket_lovelace_resources(hass, connection, msg): ) @websocket_api.async_response @_handle_errors -async def websocket_lovelace_config(hass, connection, msg, config): +async def websocket_lovelace_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + config: LovelaceStorage, +) -> None: """Send Lovelace UI config over WebSocket configuration.""" return await config.async_load(msg["force"]) @@ -79,7 +96,12 @@ async def websocket_lovelace_config(hass, connection, msg, config): ) @websocket_api.async_response @_handle_errors -async def websocket_lovelace_save_config(hass, connection, msg, config): +async def websocket_lovelace_save_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + config: LovelaceStorage, +) -> None: """Save Lovelace UI configuration.""" await config.async_save(msg["config"]) @@ -93,14 +115,23 @@ async def websocket_lovelace_save_config(hass, connection, msg, config): ) @websocket_api.async_response @_handle_errors -async def websocket_lovelace_delete_config(hass, connection, msg, config): +async def websocket_lovelace_delete_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + config: LovelaceStorage, +) -> None: """Delete Lovelace UI configuration.""" await config.async_delete() @websocket_api.websocket_command({"type": "lovelace/dashboards/list"}) @callback -def websocket_lovelace_dashboards(hass, connection, msg): +def websocket_lovelace_dashboards( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Delete Lovelace UI configuration.""" connection.send_result( msg["id"], diff --git a/homeassistant/components/luftdaten/manifest.json b/homeassistant/components/luftdaten/manifest.json index 255dc8c52eabecb68f9d4a79e2f3ab3d1e4d272d..aed8d80f8b1c0eb6f9b2682909a3ec153e33fd40 100644 --- a/homeassistant/components/luftdaten/manifest.json +++ b/homeassistant/components/luftdaten/manifest.json @@ -7,5 +7,6 @@ "codeowners": ["@fabaff", "@frenck"], "quality_scale": "gold", "iot_class": "cloud_polling", + "integration_type": "device", "loggers": ["luftdaten"] } diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index 53ab1e6af4702d5ea1ead2695799cbdce90b54a0..cb526b004de6125c2d9dd0c3f483ee794791f17b 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -2,7 +2,7 @@ "domain": "lupusec", "name": "Lupus Electronics LUPUSEC", "documentation": "https://www.home-assistant.io/integrations/lupusec", - "requirements": ["lupupy==0.0.24"], + "requirements": ["lupupy==0.1.9"], "codeowners": ["@majuss"], "iot_class": "local_polling", "loggers": ["lupupy"] diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 2041f4d65d688cc24ffa28ec8609e12d218fe708..385fdf94a627123ec44252b2ef423cc367f16ef1 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -37,6 +37,7 @@ from .const import ( CONF_CA_CERTS, CONF_CERTFILE, CONF_KEYFILE, + CONF_SUBTYPE, CONFIG_URL, DOMAIN, LUTRON_CASETA_BUTTON_EVENT, @@ -45,10 +46,11 @@ from .const import ( ) from .device_trigger import ( DEVICE_TYPE_SUBTYPE_MAP_TO_LIP, + KEYPAD_LEAP_BUTTON_NAME_OVERRIDE, LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP, - _lutron_model_to_device_type, + LUTRON_BUTTON_TRIGGER_SCHEMA, ) -from .models import LutronCasetaData +from .models import LutronButton, LutronCasetaData, LutronKeypad, LutronKeypadData from .util import serial_to_unique_id _LOGGER = logging.getLogger(__name__) @@ -79,6 +81,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.SCENE, Platform.SWITCH, + Platform.BUTTON, ] @@ -168,24 +171,25 @@ async def async_setup_entry( _LOGGER.debug("Connected to Lutron Caseta bridge via LEAP at %s", host) await _async_migrate_unique_ids(hass, config_entry) - devices = bridge.get_devices() - bridge_device = devices[BRIDGE_DEVICE_ID] + bridge_devices = bridge.get_devices() + bridge_device = bridge_devices[BRIDGE_DEVICE_ID] + if not config_entry.unique_id: hass.config_entries.async_update_entry( config_entry, unique_id=serial_to_unique_id(bridge_device["serial"]) ) - buttons = bridge.buttons - _async_register_bridge_device(hass, entry_id, bridge_device) - button_devices = _async_register_button_devices( - hass, entry_id, bridge_device, buttons - ) - _async_subscribe_pico_remote_events(hass, bridge, buttons) + _async_register_bridge_device(hass, entry_id, bridge_device, bridge) + + keypad_data = _async_setup_keypads(hass, entry_id, bridge, bridge_device) # Store this bridge (keyed by entry_id) so it can be retrieved by the # platforms we're setting up. + hass.data[DOMAIN][entry_id] = LutronCasetaData( - bridge, bridge_device, button_devices + bridge, + bridge_device, + keypad_data, ) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -195,60 +199,201 @@ async def async_setup_entry( @callback def _async_register_bridge_device( - hass: HomeAssistant, config_entry_id: str, bridge_device: dict + hass: HomeAssistant, config_entry_id: str, bridge_device: dict, bridge: Smartbridge ) -> None: """Register the bridge device in the device registry.""" device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - name=bridge_device["name"], - manufacturer=MANUFACTURER, - config_entry_id=config_entry_id, - identifiers={(DOMAIN, bridge_device["serial"])}, - model=f"{bridge_device['model']} ({bridge_device['type']})", - configuration_url="https://device-login.lutron.com", - ) + + device_args: DeviceInfo = { + "name": bridge_device["name"], + "manufacturer": MANUFACTURER, + "identifiers": {(DOMAIN, bridge_device["serial"])}, + "model": f"{bridge_device['model']} ({bridge_device['type']})", + "via_device": (DOMAIN, bridge_device["serial"]), + "configuration_url": "https://device-login.lutron.com", + } + + area = _area_name_from_id(bridge.areas, bridge_device["area"]) + if area != UNASSIGNED_AREA: + device_args["suggested_area"] = area + + device_registry.async_get_or_create(**device_args, config_entry_id=config_entry_id) @callback -def _async_register_button_devices( +def _async_setup_keypads( hass: HomeAssistant, config_entry_id: str, - bridge_device, - button_devices_by_id: dict[int, dict], -) -> dict[str, dict]: - """Register button devices (Pico Remotes) in the device registry.""" + bridge: Smartbridge, + bridge_device: dict[str, Any], +) -> LutronKeypadData: + """Register keypad devices (Keypads and Pico Remotes) in the device registry.""" + device_registry = dr.async_get(hass) - button_devices_by_dr_id: dict[str, dict] = {} - seen = set() - - for device in button_devices_by_id.values(): - if "serial" not in device or device["serial"] in seen: - continue - seen.add(device["serial"]) - area, name = _area_and_name_from_name(device["name"]) - device_args: dict[str, Any] = { - "name": f"{area} {name}", - "manufacturer": MANUFACTURER, - "config_entry_id": config_entry_id, - "identifiers": {(DOMAIN, device["serial"])}, - "model": f"{device['model']} ({device['type']})", - "via_device": (DOMAIN, bridge_device["serial"]), - } - if area != UNASSIGNED_AREA: - device_args["suggested_area"] = area - dr_device = device_registry.async_get_or_create(**device_args) - button_devices_by_dr_id[dr_device.id] = device + bridge_devices = bridge.get_devices() + bridge_buttons = bridge.buttons - return button_devices_by_dr_id + dr_device_id_to_keypad: dict[str, LutronKeypad] = {} + keypads: dict[int, LutronKeypad] = {} + keypad_buttons: dict[int, LutronButton] = {} + keypad_button_names_to_leap: dict[int, dict[str, int]] = {} + for bridge_button in bridge_buttons.values(): -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: - area_device_name = device_name.split("_", 1) - return area_device_name[0], area_device_name[1] - return UNASSIGNED_AREA, device_name + bridge_keypad = bridge_devices[bridge_button["parent_device"]] + keypad_device_id = bridge_keypad["device_id"] + button_device_id = bridge_button["device_id"] + + if not (keypad := keypads.get(keypad_device_id)): + # First time seeing this keypad, build keypad data and store in keypads + keypad = keypads[keypad_device_id] = _async_build_lutron_keypad( + bridge, bridge_device, bridge_keypad, keypad_device_id + ) + + # Register the keypad device + dr_device = device_registry.async_get_or_create( + **keypad["device_info"], config_entry_id=config_entry_id + ) + keypad["dr_device_id"] = dr_device.id + dr_device_id_to_keypad[dr_device.id] = keypad + + # Add button to parent keypad, and build keypad_buttons and keypad_button_names_to_leap + button = keypad_buttons[button_device_id] = LutronButton( + lutron_device_id=button_device_id, + leap_button_number=bridge_button["button_number"], + button_name=_get_button_name(keypad, bridge_button), + led_device_id=bridge_button.get("button_led"), + parent_keypad=keypad["lutron_device_id"], + ) + + keypad["buttons"].append(button["lutron_device_id"]) + + keypad_button_names_to_leap.setdefault(keypad["lutron_device_id"], {}).update( + {button["button_name"]: int(button["leap_button_number"])} + ) + + keypad_trigger_schemas = _async_build_trigger_schemas(keypad_button_names_to_leap) + + _async_subscribe_keypad_events(hass, bridge, keypads, keypad_buttons) + + return LutronKeypadData( + dr_device_id_to_keypad, + keypads, + keypad_buttons, + keypad_button_names_to_leap, + keypad_trigger_schemas, + ) + + +@callback +def _async_build_trigger_schemas( + keypad_button_names_to_leap: dict[int, dict[str, int]] +) -> dict[int, vol.Schema]: + """Build device trigger schemas.""" + + return { + keypad_id: LUTRON_BUTTON_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_SUBTYPE): vol.In( + keypad_button_names_to_leap[keypad_id] + ), + } + ) + for keypad_id in keypad_button_names_to_leap + } + + +@callback +def _async_build_lutron_keypad( + bridge: Smartbridge, + bridge_device: dict[str, Any], + bridge_keypad: dict[str, Any], + keypad_device_id: int, +) -> LutronKeypad: + # First time seeing this keypad, build keypad data and store in keypads + + area_name = _area_name_from_id(bridge.areas, bridge_keypad["area"]) + keypad_name = bridge_keypad["name"].split("_")[-1] + keypad_serial = _handle_none_keypad_serial(bridge_keypad, bridge_device["serial"]) + device_info = DeviceInfo( + name=f"{area_name} {keypad_name}", + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, keypad_serial)}, + model=f"{bridge_keypad['model']} ({bridge_keypad['type']})", + via_device=(DOMAIN, bridge_device["serial"]), + ) + if area_name != UNASSIGNED_AREA: + device_info["suggested_area"] = area_name + + return LutronKeypad( + lutron_device_id=keypad_device_id, + dr_device_id="", + area_id=bridge_keypad["area"], + area_name=area_name, + name=keypad_name, + serial=keypad_serial, + device_info=device_info, + model=bridge_keypad["model"], + type=bridge_keypad["type"], + buttons=[], + ) + + +def _get_button_name(keypad: LutronKeypad, bridge_button: dict[str, Any]) -> str: + """Get the LEAP button name and check for override.""" + + button_number = bridge_button["button_number"] + button_name = bridge_button.get("device_name") + + if button_name is None: + # This is a Caseta Button retrieve name from hardcoded trigger definitions. + return _get_button_name_from_triggers(keypad, button_number) + + keypad_model = keypad["model"] + if keypad_model_override := KEYPAD_LEAP_BUTTON_NAME_OVERRIDE.get(keypad_model): + if alt_button_name := keypad_model_override.get(button_number): + return alt_button_name + + return button_name + + +def _get_button_name_from_triggers(keypad: LutronKeypad, button_number: int) -> str: + """Retrieve the caseta button name from device triggers.""" + button_number_map = LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP.get(keypad["type"], {}) + return ( + button_number_map.get( + button_number, + f"button {button_number}", + ) + .replace("_", " ") + .title() + ) + + +def _handle_none_keypad_serial(keypad_device: dict, bridge_serial: int) -> str: + return keypad_device["serial"] or f"{bridge_serial}_{keypad_device['device_id']}" + + +def _area_name_from_id(areas: dict[str, dict], area_id: str | None) -> str: + """Return the full area name including parent(s).""" + if area_id is None: + return UNASSIGNED_AREA + return _construct_area_name_from_id(areas, area_id, []) + + +def _construct_area_name_from_id( + areas: dict[str, dict], area_id: str, labels: list[str] +) -> str: + """Recursively construct the full area name including parent(s).""" + area = areas[area_id] + parent_area_id = area["parent_id"] + if parent_area_id is None: + # This is the root area, return last area + return " ".join(labels) + + labels.insert(0, area["name"]) + return _construct_area_name_from_id(areas, parent_area_id, labels) @callback @@ -264,17 +409,19 @@ def async_get_lip_button(device_type: str, leap_button: int) -> int | None: @callback -def _async_subscribe_pico_remote_events( +def _async_subscribe_keypad_events( hass: HomeAssistant, - bridge_device: Smartbridge, - button_devices_by_id: dict[int, dict], + bridge: Smartbridge, + keypads: dict[int, Any], + keypad_buttons: dict[int, Any], ): """Subscribe to lutron events.""" - dev_reg = dr.async_get(hass) @callback def _async_button_event(button_id, event_type): - if not (device := button_devices_by_id.get(button_id)): + if not (button := keypad_buttons.get(button_id)) or not ( + keypad := keypads.get(button["parent_keypad"]) + ): return if event_type == BUTTON_STATUS_PRESSED: @@ -282,28 +429,26 @@ def _async_subscribe_pico_remote_events( else: action = ACTION_RELEASE - type_ = _lutron_model_to_device_type(device["model"], device["type"]) - area, name = _area_and_name_from_name(device["name"]) - leap_button_number = device["button_number"] - lip_button_number = async_get_lip_button(type_, leap_button_number) - hass_device = dev_reg.async_get_device({(DOMAIN, device["serial"])}) + keypad_type = keypad["type"] + leap_button_number = button["leap_button_number"] + lip_button_number = async_get_lip_button(keypad_type, leap_button_number) hass.bus.async_fire( LUTRON_CASETA_BUTTON_EVENT, { - ATTR_SERIAL: device["serial"], - ATTR_TYPE: type_, + ATTR_SERIAL: keypad["serial"], + ATTR_TYPE: keypad_type, ATTR_BUTTON_NUMBER: lip_button_number, ATTR_LEAP_BUTTON_NUMBER: leap_button_number, - ATTR_DEVICE_NAME: name, - ATTR_DEVICE_ID: hass_device.id, - ATTR_AREA_NAME: area, + ATTR_DEVICE_NAME: keypad["name"], + ATTR_DEVICE_ID: keypad["dr_device_id"], + ATTR_AREA_NAME: keypad["area_name"], ATTR_ACTION: action, }, ) - for button_id in button_devices_by_id: - bridge_device.add_button_subscriber( + for button_id in keypad_buttons: + bridge.add_button_subscriber( str(button_id), lambda event_type, button_id=button_id: _async_button_event( button_id, event_type @@ -327,7 +472,7 @@ class LutronCasetaDevice(Entity): _attr_should_poll = False - def __init__(self, device, bridge, bridge_device): + def __init__(self, device: dict[str, Any], data: LutronCasetaData) -> None: """Set up the base class. [:param]device the device metadata @@ -335,15 +480,25 @@ class LutronCasetaDevice(Entity): [:param]bridge_device a dict with the details of the bridge """ self._device = device - self._smartbridge = bridge - self._bridge_device = bridge_device - self._bridge_unique_id = serial_to_unique_id(bridge_device["serial"]) + self._smartbridge = data.bridge + self._bridge_device = data.bridge_device + self._bridge_unique_id = serial_to_unique_id(data.bridge_device["serial"]) if "serial" not in self._device: return - area, name = _area_and_name_from_name(device["name"]) + + if "parent_device" in device: + # This is a child entity, handle the naming in button.py and switch.py + return + area = _area_name_from_id(self._smartbridge.areas, device["area"]) + name = device["name"].split("_")[-1] self._attr_name = full_name = f"{area} {name}" info = DeviceInfo( - identifiers={(DOMAIN, self._handle_none_serial(self.serial))}, + # Historically we used the device serial number for the identifier + # but the serial is usually an integer and a string is expected + # here. Since it would be a breaking change to change the identifier + # we are ignoring the type error here until it can be migrated to + # a string in a future release. + identifiers={(DOMAIN, self._handle_none_serial(self.serial))}, # type: ignore[arg-type] manufacturer=MANUFACTURER, model=f"{device['model']} ({device['type']})", name=full_name, @@ -358,7 +513,7 @@ class LutronCasetaDevice(Entity): """Register callbacks.""" self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state) - def _handle_none_serial(self, serial: str | None) -> str | int: + def _handle_none_serial(self, serial: str | int | None) -> str | int: """Handle None serial returned by RA3 and QSX processors.""" if serial is None: return f"{self._bridge_unique_id}_{self.device_id}" @@ -370,7 +525,7 @@ class LutronCasetaDevice(Entity): return self._device["device_id"] @property - def serial(self): + def serial(self) -> int | None: """Return the serial number of the device.""" return self._device["serial"] @@ -382,7 +537,12 @@ class LutronCasetaDevice(Entity): @property def extra_state_attributes(self): """Return the state attributes.""" - return {"device_id": self.device_id, "zone_id": self._device["zone"]} + attributes = { + "device_id": self.device_id, + } + if zone := self._device.get("zone"): + attributes["zone_id"] = zone + return attributes class LutronCasetaDeviceUpdatableEntity(LutronCasetaDevice): diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 20fc221cdef3d6d5eae907d932e7ba9df0c886e7..29e59c426b53e179cbe69c8b9f0752a0f916594b 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -6,13 +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 CONFIG_URL, MANUFACTURER +from . import DOMAIN as CASETA_DOMAIN, LutronCasetaDevice, _area_name_from_id +from .const import CONFIG_URL, MANUFACTURER, UNASSIGNED_AREA from .models import LutronCasetaData @@ -28,10 +29,9 @@ async def async_setup_entry( """ 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) + LutronOccupancySensor(occupancy_group, data) for occupancy_group in occupancy_groups.values() ) @@ -41,10 +41,11 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.OCCUPANCY - def __init__(self, device, bridge, bridge_device): + def __init__(self, device, data): """Init an occupancy sensor.""" - super().__init__(device, bridge, bridge_device) - _, name = _area_and_name_from_name(device["name"]) + super().__init__(device, data) + area = _area_name_from_id(self._smartbridge.areas, device["area"]) + name = f"{area} {device['device_name']}" self._attr_name = name self._attr_device_info = DeviceInfo( identifiers={(CASETA_DOMAIN, self.unique_id)}, @@ -55,6 +56,8 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): configuration_url=CONFIG_URL, entry_type=DeviceEntryType.SERVICE, ) + if area != UNASSIGNED_AREA: + self._attr_device_info[ATTR_SUGGESTED_AREA] = area @property def is_on(self): diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py new file mode 100644 index 0000000000000000000000000000000000000000..ee73767308222f5c97c989d2be1a7c4edeae0106 --- /dev/null +++ b/homeassistant/components/lutron_caseta/button.py @@ -0,0 +1,93 @@ +"""Support for pico and keypad buttons.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.button import ButtonEntity +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 LutronCasetaDevice +from .const import DOMAIN as CASETA_DOMAIN +from .device_trigger import LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP +from .models import LutronCasetaData + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Lutron pico and keypad buttons.""" + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + button_devices = bridge.get_buttons() + all_devices = data.bridge.get_devices() + keypads = data.keypad_data.keypads + entities: list[LutronCasetaButton] = [] + + for device in button_devices.values(): + + parent_keypad = keypads[device["parent_device"]] + parent_device_info = parent_keypad["device_info"] + + enabled_default = True + if not (device_name := device.get("device_name")): + # device name (button name) is missing, probably a caseta pico + # try to get the name using the button number from the triggers + # disable the button by default + enabled_default = False + keypad_device = all_devices[device["parent_device"]] + button_numbers = LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP.get( + keypad_device["type"], + {}, + ) + device_name = ( + button_numbers.get( + int(device["button_number"]), + f"button {device['button_number']}", + ) + .replace("_", " ") + .title() + ) + + # Append the child device name to the end of the parent keypad name to create the entity name + full_name = f'{parent_device_info.get("name")} {device_name}' + # Set the device_info to the same as the Parent Keypad + # The entities will be nested inside the keypad device + entities.append( + LutronCasetaButton( + device, data, full_name, enabled_default, parent_device_info + ), + ) + + async_add_entities(entities) + + +class LutronCasetaButton(LutronCasetaDevice, ButtonEntity): + """Representation of a Lutron pico and keypad button.""" + + def __init__( + self, + device: dict[str, Any], + data: LutronCasetaData, + full_name: str, + enabled_default: bool, + device_info: DeviceInfo, + ) -> None: + """Init a button entity.""" + super().__init__(device, data) + self._attr_entity_registry_enabled_default = enabled_default + self._attr_name = full_name + self._attr_device_info = device_info + + async def async_press(self) -> None: + """Send a button press event.""" + await self._smartbridge.tap_button(self.device_id) + + @property + def serial(self): + """Buttons shouldn't have serial numbers, Return None.""" + return None diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index d63c1191d57e10a401ff987e40ec5817b6f43096..cca04e0a2980e36b41702b74bc7eea83f5b7d30a 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -30,11 +30,9 @@ async def async_setup_entry( """ 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) - for cover_device in cover_devices + LutronCasetaCover(cover_device, data) for cover_device in cover_devices ) diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index b9fe89edf7f6c07ab18a52395e624bdf35841586..2dfbe526c93bd9ebcc28ae5a0154baf5df1ea603 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -1,6 +1,8 @@ """Provides device triggers for lutron caseta.""" from __future__ import annotations +import logging + import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA @@ -17,7 +19,6 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -33,19 +34,14 @@ from .const import ( ) from .models import LutronCasetaData +_LOGGER = logging.getLogger(__name__) + def _reverse_dict(forward_dict: dict) -> dict: """Reverse a dictionary.""" return {v: k for k, v in forward_dict.items()} -LUTRON_MODEL_TO_TYPE = { - "RRST-W2B-XX": "SunnataKeypad_2Button", - "RRST-W3RL-XX": "SunnataKeypad_3ButtonRaiseLower", - "RRST-W4B-XX": "SunnataKeypad_4Button", -} - - SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_RELEASE] LUTRON_BUTTON_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( @@ -55,6 +51,20 @@ LUTRON_BUTTON_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) +KEYPAD_LEAP_BUTTON_NAME_OVERRIDE = { + "RRD-W2RLD": { + 17: "raise_1", + 16: "lower_1", + 19: "raise_2", + 18: "lower_2", + }, + "RRD-W1RLD": { + 19: "raise", + 18: "lower", + }, +} + + PICO_2_BUTTON_BUTTON_TYPES_TO_LIP = { "on": 2, "off": 4, @@ -271,51 +281,6 @@ FOUR_GROUP_REMOTE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( ) -SUNNATA_KEYPAD_2_BUTTON_BUTTON_TYPES_TO_LEAP = { - "button_1": 1, - "button_2": 2, -} -SUNNATA_KEYPAD_2_BUTTON_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( - { - vol.Required(CONF_SUBTYPE): vol.In( - SUNNATA_KEYPAD_2_BUTTON_BUTTON_TYPES_TO_LEAP - ), - } -) - - -SUNNATA_KEYPAD_3_BUTTON_RAISE_LOWER_BUTTON_TYPES_TO_LEAP = { - "button_1": 1, - "button_2": 2, - "button_3": 3, - "raise": 19, - "lower": 18, -} -SUNNATA_KEYPAD_3_BUTTON_RAISE_LOWER_TRIGGER_SCHEMA = ( - LUTRON_BUTTON_TRIGGER_SCHEMA.extend( - { - vol.Required(CONF_SUBTYPE): vol.In( - SUNNATA_KEYPAD_3_BUTTON_RAISE_LOWER_BUTTON_TYPES_TO_LEAP - ), - } - ) -) - -SUNNATA_KEYPAD_4_BUTTON_BUTTON_TYPES_TO_LEAP = { - "button_1": 1, - "button_2": 2, - "button_3": 3, - "button_4": 4, -} -SUNNATA_KEYPAD_4_BUTTON_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( - { - vol.Required(CONF_SUBTYPE): vol.In( - SUNNATA_KEYPAD_4_BUTTON_BUTTON_TYPES_TO_LEAP - ), - } -) - - DEVICE_TYPE_SCHEMA_MAP = { "Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA, "Pico2ButtonRaiseLower": PICO_2_BUTTON_RAISE_LOWER_TRIGGER_SCHEMA, @@ -326,9 +291,6 @@ DEVICE_TYPE_SCHEMA_MAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, "FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, - "SunnataKeypad_2Button": SUNNATA_KEYPAD_2_BUTTON_TRIGGER_SCHEMA, - "SunnataKeypad_3ButtonRaiseLower": SUNNATA_KEYPAD_3_BUTTON_RAISE_LOWER_TRIGGER_SCHEMA, - "SunnataKeypad_4Button": SUNNATA_KEYPAD_4_BUTTON_TRIGGER_SCHEMA, } DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { @@ -353,9 +315,6 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP, - "SunnataKeypad_2Button": SUNNATA_KEYPAD_2_BUTTON_BUTTON_TYPES_TO_LEAP, - "SunnataKeypad_3ButtonRaiseLower": SUNNATA_KEYPAD_3_BUTTON_RAISE_LOWER_BUTTON_TYPES_TO_LEAP, - "SunnataKeypad_4Button": SUNNATA_KEYPAD_4_BUTTON_BUTTON_TYPES_TO_LEAP, } LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP = { @@ -370,30 +329,52 @@ TRIGGER_SCHEMA = vol.Any( PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, - SUNNATA_KEYPAD_2_BUTTON_TRIGGER_SCHEMA, - SUNNATA_KEYPAD_3_BUTTON_RAISE_LOWER_TRIGGER_SCHEMA, - SUNNATA_KEYPAD_4_BUTTON_TRIGGER_SCHEMA, ) async def async_validate_trigger_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: - """Validate config.""" - # if device is available verify parameters against device capabilities - device = get_button_device_by_dr_id(hass, config[CONF_DEVICE_ID]) + """Validate trigger config.""" - if not device: + device_id = config[CONF_DEVICE_ID] + subtype = config[CONF_SUBTYPE] + + if not (data := get_lutron_data_by_dr_id(hass, device_id)) or not ( + keypad := data.keypad_data.dr_device_id_to_keypad.get(device_id) + ): return config + keypad_trigger_schemas = data.keypad_data.trigger_schemas + keypad_button_names_to_leap = data.keypad_data.button_names_to_leap + + # Retrieve trigger schema, preferring hard-coded triggers from device_trigger.py if not ( schema := DEVICE_TYPE_SCHEMA_MAP.get( - _lutron_model_to_device_type(device["model"], device["type"]) + keypad["type"], + keypad_trigger_schemas.get(keypad["lutron_device_id"]), ) ): - raise InvalidDeviceAutomationConfig( - f"Device model {device['model']} with type {device['type']} not supported: {config[CONF_DEVICE_ID]}" + # Trigger schema not found - log error + _LOGGER.error( + "Cannot validate trigger %s because the trigger schema was not found", + config, ) + return config + + # Retrieve list of valid buttons, preferring hard-coded triggers from device_trigger.py + device_type = keypad["type"] + valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP.get( + device_type, + keypad_button_names_to_leap[keypad["lutron_device_id"]], + ) + + if subtype not in valid_buttons: + # Trigger subtype is invalid - raise error + _LOGGER.error( + "Cannot validate trigger %s because subtype %s is invalid", config, subtype + ) + return config return schema(config) @@ -404,12 +385,18 @@ async def async_get_triggers( """List device triggers for lutron caseta devices.""" triggers = [] - if not (device := get_button_device_by_dr_id(hass, device_id)): - # Check if device is a valid button device. Return empty if not. + # Check if device is a valid keypad. Return empty if not. + if not (data := get_lutron_data_by_dr_id(hass, device_id)) or not ( + keypad := data.keypad_data.dr_device_id_to_keypad.get(device_id) + ): return [] + keypad_button_names_to_leap = data.keypad_data.button_names_to_leap + + # Retrieve list of valid buttons, preferring hard-coded triggers from device_trigger.py valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP.get( - _lutron_model_to_device_type(device["model"], device["type"]), {} + keypad["type"], + keypad_button_names_to_leap[keypad["lutron_device_id"]], ) for trigger in SUPPORTED_INPUTS_EVENTS_TYPES: @@ -427,18 +414,6 @@ async def async_get_triggers( return triggers -def _device_model_to_type(device_registry_model: str) -> str: - """Convert a lutron_caseta device registry entry model to type.""" - model, p_device_type = device_registry_model.split(" ") - device_type = p_device_type.replace("(", "").replace(")", "") - return _lutron_model_to_device_type(model, device_type) - - -def _lutron_model_to_device_type(model: str, device_type: str) -> str: - """Get the mapped type based on the lutron model or type.""" - return LUTRON_MODEL_TO_TYPE.get(model, device_type) - - async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, @@ -446,42 +421,63 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - device_registry = dr.async_get(hass) - if ( - not (device := device_registry.async_get(config[CONF_DEVICE_ID])) - or not device.model + device_id = config[CONF_DEVICE_ID] + subtype = config[CONF_SUBTYPE] + if not (data := get_lutron_data_by_dr_id(hass, device_id)) or not ( + keypad := data.keypad_data.dr_device_id_to_keypad[device_id] ): raise HomeAssistantError( - f"Cannot attach trigger {config} because device with id {config[CONF_DEVICE_ID]} is missing or invalid" + f"Cannot attach trigger {config} because device with id {device_id} is missing or invalid" + ) + + keypad_trigger_schemas = data.keypad_data.trigger_schemas + keypad_button_names_to_leap = data.keypad_data.button_names_to_leap + + device_type = keypad["type"] + serial = keypad["serial"] + lutron_device_id = keypad["lutron_device_id"] + + # Retrieve trigger schema, preferring hard-coded triggers from device_trigger.py + schema = DEVICE_TYPE_SCHEMA_MAP.get( + device_type, + keypad_trigger_schemas[lutron_device_id], + ) + + # Retrieve list of valid buttons, preferring hard-coded triggers from device_trigger.py + valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP.get( + device_type, + keypad_button_names_to_leap[lutron_device_id], + ) + + if subtype not in valid_buttons: + raise InvalidDeviceAutomationConfig( + f"Cannot attach trigger {config} because subtype {subtype} is invalid" ) - device_type = _device_model_to_type(device.model) - _, serial = list(device.identifiers)[0] - schema = DEVICE_TYPE_SCHEMA_MAP[device_type] - valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP[device_type] + config = schema(config) event_config = { event_trigger.CONF_PLATFORM: CONF_EVENT, event_trigger.CONF_EVENT_TYPE: LUTRON_CASETA_BUTTON_EVENT, event_trigger.CONF_EVENT_DATA: { ATTR_SERIAL: serial, - ATTR_LEAP_BUTTON_NUMBER: valid_buttons[config[CONF_SUBTYPE]], + ATTR_LEAP_BUTTON_NUMBER: valid_buttons[subtype], ATTR_ACTION: config[CONF_TYPE], }, } event_config = event_trigger.TRIGGER_SCHEMA(event_config) + return await event_trigger.async_attach_trigger( hass, event_config, action, trigger_info, platform_type="device" ) -def get_button_device_by_dr_id(hass: HomeAssistant, device_id: str): - """Get a lutron device for the given device id.""" +def get_lutron_data_by_dr_id(hass: HomeAssistant, device_id: str): + """Get a lutron integration data for the given device registry device id.""" if DOMAIN not in hass.data: return None for entry_id in hass.data[DOMAIN]: data: LutronCasetaData = hass.data[DOMAIN][entry_id] - if device := data.button_devices.get(device_id): - return device - + if data.keypad_data.dr_device_id_to_keypad.get(device_id): + return data return None diff --git a/homeassistant/components/lutron_caseta/diagnostics.py b/homeassistant/components/lutron_caseta/diagnostics.py index afe69b813f9b2c5dbed8ccfe17423914bcf147bc..07bd0a9e8ced97b60e6719e0fc1f5ed22c0e7d36 100644 --- a/homeassistant/components/lutron_caseta/diagnostics.py +++ b/homeassistant/components/lutron_caseta/diagnostics.py @@ -21,11 +21,16 @@ async def async_get_config_entry_diagnostics( "title": entry.title, "data": dict(entry.data), }, - "data": { + "bridge_data": { "devices": bridge.devices, "buttons": bridge.buttons, "scenes": bridge.scenes, "occupancy_groups": bridge.occupancy_groups, "areas": bridge.areas, }, + "integration_data": { + "keypad_button_names_to_leap": data.keypad_data.button_names_to_leap, + "keypad_buttons": data.keypad_data.buttons, + "keypads": data.keypad_data.keypads, + }, } diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index bf2328565d4331a9ce513ba18a636d7bae8418f9..ba69f17d88086b084074591b9ff78b29ce5b3ce5 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -34,11 +34,8 @@ async def async_setup_entry( """ 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 - ) + async_add_entities(LutronCasetaFan(fan_device, data) for fan_device in fan_devices) class LutronCasetaFan(LutronCasetaDeviceUpdatableEntity, FanEntity): diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index cfad8115a208d340852e75446745636a0ae34565..ffab06896366c3f5f6e8944c6246c0e2bd54982c 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -41,11 +41,9 @@ async def async_setup_entry( """ 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) - for light_device in light_devices + LutronCasetaLight(light_device, data) for light_device in light_devices ) diff --git a/homeassistant/components/lutron_caseta/logbook.py b/homeassistant/components/lutron_caseta/logbook.py index 7bf1b467ff683d4e8ae7f13b657048bbdcde8b84..ccefaff2a78d3736ac67832a7038d570cbd16b00 100644 --- a/homeassistant/components/lutron_caseta/logbook.py +++ b/homeassistant/components/lutron_caseta/logbook.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME +from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback from .const import ( @@ -15,7 +16,11 @@ from .const import ( DOMAIN, LUTRON_CASETA_BUTTON_EVENT, ) -from .device_trigger import LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP +from .device_trigger import ( + LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP, + _reverse_dict, + get_lutron_data_by_dr_id, +) @callback @@ -28,11 +33,28 @@ def async_describe_events( @callback def async_describe_button_event(event: Event) -> dict[str, str]: """Describe lutron_caseta_button_event logbook event.""" + data = event.data device_type = data[ATTR_TYPE] leap_button_number = data[ATTR_LEAP_BUTTON_NUMBER] - button_map = LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP[device_type] - button_description = button_map[leap_button_number] + dr_device_id = data[ATTR_DEVICE_ID] + lutron_data = get_lutron_data_by_dr_id(hass, dr_device_id) + keypad = lutron_data.keypad_data.dr_device_id_to_keypad.get(dr_device_id) + keypad_id = keypad["lutron_device_id"] + + keypad_button_names_to_leap = lutron_data.keypad_data.button_names_to_leap + + if not (rev_button_map := LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP.get(device_type)): + if fwd_button_map := keypad_button_names_to_leap.get(keypad_id): + rev_button_map = _reverse_dict(fwd_button_map) + + if rev_button_map is None: + return { + LOGBOOK_ENTRY_NAME: f"{data[ATTR_AREA_NAME]} {data[ATTR_DEVICE_NAME]}", + LOGBOOK_ENTRY_MESSAGE: f"{data[ATTR_ACTION]} Error retrieving button description", + } + + button_description = rev_button_map.get(leap_button_number) return { LOGBOOK_ENTRY_NAME: f"{data[ATTR_AREA_NAME]} {data[ATTR_DEVICE_NAME]}", LOGBOOK_ENTRY_MESSAGE: f"{data[ATTR_ACTION]} {button_description}", diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index 88849391e24cd268c6a8afb66c358335f86f107a..d65ca852da711f45e556c14d955ebd53d44bdf1f 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -2,9 +2,22 @@ "domain": "lutron_caseta", "name": "Lutron Cas\u00e9ta", "documentation": "https://www.home-assistant.io/integrations/lutron_caseta", - "requirements": ["pylutron-caseta==0.15.2"], + "requirements": ["pylutron-caseta==0.17.1"], "config_flow": true, - "zeroconf": ["_leap._tcp.local."], + "zeroconf": [ + { + "type": "_lutron._tcp.local.", + "properties": { "SYSTYPE": "radiora3*" } + }, + { + "type": "_lutron._tcp.local.", + "properties": { "SYSTYPE": "smartbridge*" } + }, + { + "type": "_lutron._tcp.local.", + "properties": { "SYSTYPE": "ra2select*" } + } + ], "homekit": { "models": ["Smart Bridge"] }, diff --git a/homeassistant/components/lutron_caseta/models.py b/homeassistant/components/lutron_caseta/models.py index 362760b0caf9934e8dee38eb3fbcba56e64c9f45..576387bd36b661a359b39a179213906c6309da3b 100644 --- a/homeassistant/components/lutron_caseta/models.py +++ b/homeassistant/components/lutron_caseta/models.py @@ -2,9 +2,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, TypedDict from pylutron_caseta.smartbridge import Smartbridge +import voluptuous as vol + +from homeassistant.helpers.entity import DeviceInfo @dataclass @@ -13,4 +16,40 @@ class LutronCasetaData: bridge: Smartbridge bridge_device: dict[str, Any] - button_devices: dict[str, dict] + keypad_data: LutronKeypadData + + +@dataclass +class LutronKeypadData: + """Data for the lutron_caseta integration keypads.""" + + dr_device_id_to_keypad: dict[str, LutronKeypad] + keypads: dict[int, LutronKeypad] + buttons: dict[int, LutronButton] + button_names_to_leap: dict[int, dict[str, int]] + trigger_schemas: dict[int, vol.Schema] + + +class LutronKeypad(TypedDict): + """A lutron_caseta keypad device.""" + + lutron_device_id: int + dr_device_id: str + area_id: int + area_name: str + name: str + serial: str + device_info: DeviceInfo + model: str + type: str + buttons: list[int] + + +class LutronButton(TypedDict): + """A lutron_caseta button.""" + + lutron_device_id: int + leap_button_number: int + button_name: str + led_device_id: int + parent_keypad: int diff --git a/homeassistant/components/lutron_caseta/scene.py b/homeassistant/components/lutron_caseta/scene.py index 2870d6ee96ac72c4dd60efe9dfa05610ee25ffc4..997397c5b6c45eb93a807204184829e7503879de 100644 --- a/homeassistant/components/lutron_caseta/scene.py +++ b/homeassistant/components/lutron_caseta/scene.py @@ -9,7 +9,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -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 @@ -27,25 +26,22 @@ async def async_setup_entry( """ 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, bridge_device) for scene in scenes - ) + async_add_entities(LutronCasetaScene(scenes[scene], data) for scene in scenes) class LutronCasetaScene(Scene): """Representation of a Lutron Caseta scene.""" - def __init__(self, scene, bridge, bridge_device): + def __init__(self, scene, data): """Initialize the Lutron Caseta scene.""" self._scene_id = scene["scene_id"] - self._bridge: Smartbridge = bridge - bridge_unique_id = serial_to_unique_id(bridge_device["serial"]) + self._bridge: Smartbridge = data.bridge + bridge_unique_id = serial_to_unique_id(data.bridge_device["serial"]) self._attr_device_info = DeviceInfo( - identifiers={(CASETA_DOMAIN, bridge_device["serial"])}, + identifiers={(CASETA_DOMAIN, data.bridge_device["serial"])}, ) - self._attr_name = _area_and_name_from_name(scene["name"])[1] + self._attr_name = scene["name"] self._attr_unique_id = f"scene_{bridge_unique_id}_{self._scene_id}" async def async_activate(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json index a89b0c4bbce2244d83a42c6ae8c457dab425fdd9..0c6ec06005c65f4c2455ea79428667c4c08559e7 100644 --- a/homeassistant/components/lutron_caseta/strings.json +++ b/homeassistant/components/lutron_caseta/strings.json @@ -33,6 +33,9 @@ "button_2": "Second button", "button_3": "Third button", "button_4": "Fourth button", + "button_5": "Fifth button", + "button_6": "Sixth button", + "button_7": "Seventh button", "group_1_button_1": "First Group first button", "group_1_button_2": "First Group second button", "group_2_button_1": "Second Group first button", diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index 92ec6b35f984f5039c29834967dd6bb839153f15..795435d5f7cf0c49cf11f8d8e61831f1daa4f773 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -24,17 +24,33 @@ async def async_setup_entry( """ 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) - for switch_device in switch_devices + LutronCasetaLight(switch_device, data) for switch_device in switch_devices ) class LutronCasetaLight(LutronCasetaDeviceUpdatableEntity, SwitchEntity): """Representation of a Lutron Caseta switch.""" + def __init__(self, device, data): + """Init a button entity.""" + + super().__init__(device, data) + self._enabled_default = True + + if "parent_device" not in device: + return + + keypads = data.keypad_data.keypads + parent_keypad = keypads[device["parent_device"]] + parent_device_info = parent_keypad["device_info"] + # Append the child device name to the end of the parent keypad name to create the entity name + self._attr_name = f'{parent_device_info["name"]} {device["device_name"]}' + # Set the device_info to the same as the Parent Keypad + # The entities will be nested inside the keypad device + self._attr_device_info = parent_device_info + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self._smartbridge.turn_on(self.device_id) diff --git a/homeassistant/components/lutron_caseta/translations/bg.json b/homeassistant/components/lutron_caseta/translations/bg.json index 11705bad144e1e2e9219c053a705abfe0004934b..d5d9da2e3782dab07ac831b7218b2776c269af99 100644 --- a/homeassistant/components/lutron_caseta/translations/bg.json +++ b/homeassistant/components/lutron_caseta/translations/bg.json @@ -19,6 +19,9 @@ "button_2": "\u0412\u0442\u043e\u0440\u0438 \u0431\u0443\u0442\u043e\u043d", "button_3": "\u0422\u0440\u0435\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", "button_4": "\u0427\u0435\u0442\u0432\u044a\u0440\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_5": "\u041f\u0435\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_6": "\u0428\u0435\u0441\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_7": "\u0421\u0435\u0434\u043c\u0438 \u0431\u0443\u0442\u043e\u043d", "off": "\u0418\u0437\u043a\u043b.", "on": "\u0412\u043a\u043b." } diff --git a/homeassistant/components/lutron_caseta/translations/ca.json b/homeassistant/components/lutron_caseta/translations/ca.json index de714fea7269d2135e3256a04493925b924cd352..b519cd2a86478445853345a57d5dd5feea258dd7 100644 --- a/homeassistant/components/lutron_caseta/translations/ca.json +++ b/homeassistant/components/lutron_caseta/translations/ca.json @@ -33,6 +33,9 @@ "button_2": "Segon bot\u00f3", "button_3": "Tercer bot\u00f3", "button_4": "Quart bot\u00f3", + "button_5": "Cinqu\u00e8 bot\u00f3", + "button_6": "Sis\u00e8 bot\u00f3", + "button_7": "Set\u00e8 bot\u00f3", "close_1": "Tanca 1", "close_2": "Tanca 2", "close_3": "Tanca 3", diff --git a/homeassistant/components/lutron_caseta/translations/de.json b/homeassistant/components/lutron_caseta/translations/de.json index 4bd8e2a59310b47b07d7e4d8d7667599c99f523d..ff4f89aa5125d6314814610780ff0fd4a8b56da3 100644 --- a/homeassistant/components/lutron_caseta/translations/de.json +++ b/homeassistant/components/lutron_caseta/translations/de.json @@ -33,6 +33,9 @@ "button_2": "Zweite Taste", "button_3": "Dritte Taste", "button_4": "Vierte Taste", + "button_5": "5. Taste", + "button_6": "6. Taste", + "button_7": "7. Taste", "close_1": "Einen schlie\u00dfen", "close_2": "Zwei schlie\u00dfen", "close_3": "Drei schlie\u00dfen", diff --git a/homeassistant/components/lutron_caseta/translations/en.json b/homeassistant/components/lutron_caseta/translations/en.json index d5245dae2a4106a98fbc266f81fa032c98d0d5b2..b0ddf459194c82a3d5a5270ff8fe666c48005a81 100644 --- a/homeassistant/components/lutron_caseta/translations/en.json +++ b/homeassistant/components/lutron_caseta/translations/en.json @@ -33,6 +33,9 @@ "button_2": "Second button", "button_3": "Third button", "button_4": "Fourth button", + "button_5": "Fifth button", + "button_6": "Sixth button", + "button_7": "Seventh button", "close_1": "Close 1", "close_2": "Close 2", "close_3": "Close 3", diff --git a/homeassistant/components/lutron_caseta/translations/es.json b/homeassistant/components/lutron_caseta/translations/es.json index 95559e14baf769b6491b45b488f34d3a05beef9f..c17b0212332f2ea4f249b299bde0d63ada1059dd 100644 --- a/homeassistant/components/lutron_caseta/translations/es.json +++ b/homeassistant/components/lutron_caseta/translations/es.json @@ -33,6 +33,9 @@ "button_2": "Segundo bot\u00f3n", "button_3": "Tercer bot\u00f3n", "button_4": "Cuarto bot\u00f3n", + "button_5": "Quinto bot\u00f3n", + "button_6": "Sexto bot\u00f3n", + "button_7": "S\u00e9ptimo bot\u00f3n", "close_1": "Cerrar 1", "close_2": "Cerrar 2", "close_3": "Cerrar 3", diff --git a/homeassistant/components/lutron_caseta/translations/et.json b/homeassistant/components/lutron_caseta/translations/et.json index b6d73a920d4ea9074583994548d8fa6eea9f8555..24f6fef4ac35a2b9a8332caa496f9a722f8124d8 100644 --- a/homeassistant/components/lutron_caseta/translations/et.json +++ b/homeassistant/components/lutron_caseta/translations/et.json @@ -33,6 +33,9 @@ "button_2": "Teine nupp", "button_3": "Kolmas nupp", "button_4": "Neljas nupp", + "button_5": "Viies nupp", + "button_6": "Kuues nupp", + "button_7": "Seitsmes nupp", "close_1": "Sule #1", "close_2": "Sule #2", "close_3": "Sule #3", diff --git a/homeassistant/components/lutron_caseta/translations/fr.json b/homeassistant/components/lutron_caseta/translations/fr.json index c5f259167d2d61be18312d3858ac453e88035755..fd07ef3e87e548b2a33d9fda5f94ddc3c296a7c7 100644 --- a/homeassistant/components/lutron_caseta/translations/fr.json +++ b/homeassistant/components/lutron_caseta/translations/fr.json @@ -33,6 +33,9 @@ "button_2": "Deuxi\u00e8me bouton", "button_3": "Troisi\u00e8me bouton", "button_4": "Quatri\u00e8me bouton", + "button_5": "Cinqui\u00e8me bouton", + "button_6": "Sixi\u00e8me bouton", + "button_7": "Septi\u00e8me bouton", "close_1": "Fermer 1", "close_2": "Fermer 2", "close_3": "Fermer 3", diff --git a/homeassistant/components/lutron_caseta/translations/hu.json b/homeassistant/components/lutron_caseta/translations/hu.json index 1cbca645b6c9c9351fe9648b630c7bd70081910e..5796d1b2816e2587b8153806d89cf3b2e0ab688a 100644 --- a/homeassistant/components/lutron_caseta/translations/hu.json +++ b/homeassistant/components/lutron_caseta/translations/hu.json @@ -33,6 +33,9 @@ "button_2": "M\u00e1sodik gomb", "button_3": "Harmadik gomb", "button_4": "Negyedik gomb", + "button_5": "\u00d6t\u00f6dik gomb", + "button_6": "Hatodik gomb", + "button_7": "Hetedik gomb", "close_1": "Bez\u00e1r\u00e1s 1.", "close_2": "Bez\u00e1r\u00e1s 2.", "close_3": "Bez\u00e1r\u00e1s 3.", diff --git a/homeassistant/components/lutron_caseta/translations/id.json b/homeassistant/components/lutron_caseta/translations/id.json index 7789d784d23f00b4a5ad0356a1dc5dc6db2f75aa..66d768c20ac8bfe65e91bada1d7d2485604eb83d 100644 --- a/homeassistant/components/lutron_caseta/translations/id.json +++ b/homeassistant/components/lutron_caseta/translations/id.json @@ -33,6 +33,9 @@ "button_2": "Tombol kedua", "button_3": "Tombol ketiga", "button_4": "Tombol keempat", + "button_5": "Tombol kelima", + "button_6": "Tombol keenam", + "button_7": "Tombol ketujuh", "close_1": "Tutup 1", "close_2": "Tutup 2", "close_3": "Tutup 3", diff --git a/homeassistant/components/lutron_caseta/translations/it.json b/homeassistant/components/lutron_caseta/translations/it.json index 8b412cfa40f34591d06846d003af0daa74795c76..c013d08b5758a7f4969d91deb5833155bdc9b294 100644 --- a/homeassistant/components/lutron_caseta/translations/it.json +++ b/homeassistant/components/lutron_caseta/translations/it.json @@ -33,6 +33,9 @@ "button_2": "Secondo pulsante", "button_3": "Terzo pulsante", "button_4": "Quarto pulsante", + "button_5": "Quinto pulsante", + "button_6": "Sesto pulsante", + "button_7": "Settimo pulsante", "close_1": "Chiudi 1", "close_2": "Chiudi 2", "close_3": "Chiudi 3", diff --git a/homeassistant/components/lutron_caseta/translations/ja.json b/homeassistant/components/lutron_caseta/translations/ja.json index f32e47e99429e53d5fa34a43c849828b8327441c..615ee90bdede592d6b1f66bfca09d291ad0f3048 100644 --- a/homeassistant/components/lutron_caseta/translations/ja.json +++ b/homeassistant/components/lutron_caseta/translations/ja.json @@ -33,6 +33,9 @@ "button_2": "2\u756a\u76ee\u306e\u30dc\u30bf\u30f3", "button_3": "3\u756a\u76ee\u306e\u30dc\u30bf\u30f3", "button_4": "4\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button_5": "5\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button_6": "6\u756a\u76ee\u306e\u30dc\u30bf\u30f3", + "button_7": "7\u756a\u76ee\u306e\u30dc\u30bf\u30f3", "close_1": "\u30af\u30ed\u30fc\u30ba1", "close_2": "\u30af\u30ed\u30fc\u30ba2", "close_3": "\u30af\u30ed\u30fc\u30ba3", diff --git a/homeassistant/components/lutron_caseta/translations/nl.json b/homeassistant/components/lutron_caseta/translations/nl.json index 0d1063eee8cc4ef1bcafcfea5b4c831a1ef53c49..8f50e2ddd697c0496f34baa8672d75221825bee9 100644 --- a/homeassistant/components/lutron_caseta/translations/nl.json +++ b/homeassistant/components/lutron_caseta/translations/nl.json @@ -33,6 +33,9 @@ "button_2": "Tweede knop", "button_3": "Derde knop", "button_4": "Vierde knop", + "button_5": "Vijfde knop", + "button_6": "Zesde knop", + "button_7": "Zevende knop", "close_1": "Sluit 1", "close_2": "Sluit 2", "close_3": "Sluit 3", diff --git a/homeassistant/components/lutron_caseta/translations/no.json b/homeassistant/components/lutron_caseta/translations/no.json index 91e7bc2800749f5d3525f948226a72246ff567c1..e5f8e72330ded3eb73652a2abf81dac9a572e616 100644 --- a/homeassistant/components/lutron_caseta/translations/no.json +++ b/homeassistant/components/lutron_caseta/translations/no.json @@ -33,6 +33,9 @@ "button_2": "Andre knapp", "button_3": "Tredje knapp", "button_4": "Fjerde knapp", + "button_5": "Femte knapp", + "button_6": "Sjette knapp", + "button_7": "Syvende knapp", "close_1": "Lukk 1", "close_2": "Lukk 2", "close_3": "Lukk 3", diff --git a/homeassistant/components/lutron_caseta/translations/pl.json b/homeassistant/components/lutron_caseta/translations/pl.json index 47e6a07e146a47cc03140fb59e8210f408ed5a72..a37360eb937a9934542293ba2e18223977999a02 100644 --- a/homeassistant/components/lutron_caseta/translations/pl.json +++ b/homeassistant/components/lutron_caseta/translations/pl.json @@ -33,6 +33,9 @@ "button_2": "drugi", "button_3": "trzeci", "button_4": "czwarty", + "button_5": "pi\u0105ty", + "button_6": "sz\u00f3sty", + "button_7": "si\u00f3dmy", "close_1": "zamknij 1", "close_2": "zamknij 2", "close_3": "zamknij 3", diff --git a/homeassistant/components/lutron_caseta/translations/pt-BR.json b/homeassistant/components/lutron_caseta/translations/pt-BR.json index 28a85a8820deae5b71b0555e0912b5d19b0a52a7..274d7d25b953db324c38962ecd6f55940ea09df2 100644 --- a/homeassistant/components/lutron_caseta/translations/pt-BR.json +++ b/homeassistant/components/lutron_caseta/translations/pt-BR.json @@ -33,6 +33,9 @@ "button_2": "Segundo bot\u00e3o", "button_3": "Terceiro bot\u00e3o", "button_4": "Quarto bot\u00e3o", + "button_5": "Quinto bot\u00e3o", + "button_6": "Sexto bot\u00e3o", + "button_7": "S\u00e9timo bot\u00e3o", "close_1": "Fechar 1", "close_2": "Fechar 2", "close_3": "Fechar 3", diff --git a/homeassistant/components/lutron_caseta/translations/ru.json b/homeassistant/components/lutron_caseta/translations/ru.json index 090af1923f30417fdd3f4e31169d2c20d8a0176d..07705b270bac647bc684f35e20b5524e824afcbf 100644 --- a/homeassistant/components/lutron_caseta/translations/ru.json +++ b/homeassistant/components/lutron_caseta/translations/ru.json @@ -33,6 +33,9 @@ "button_2": "\u0412\u0442\u043e\u0440\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", "button_3": "\u0422\u0440\u0435\u0442\u044c\u044f \u043a\u043d\u043e\u043f\u043a\u0430", "button_4": "\u0427\u0435\u0442\u0432\u0435\u0440\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_5": "\u041f\u044f\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_6": "\u0428\u0435\u0441\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_7": "\u0421\u0435\u0434\u044c\u043c\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", "close_1": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c 1", "close_2": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c 2", "close_3": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c 3", diff --git a/homeassistant/components/lutron_caseta/translations/tr.json b/homeassistant/components/lutron_caseta/translations/tr.json index 54c5530224ca5f3cfc301ce71591215853d50b5e..df94879f3321e512cc5ae105eeff1225bcd84ea4 100644 --- a/homeassistant/components/lutron_caseta/translations/tr.json +++ b/homeassistant/components/lutron_caseta/translations/tr.json @@ -33,6 +33,9 @@ "button_2": "\u0130kinci d\u00fc\u011fme", "button_3": "\u00dc\u00e7\u00fcnc\u00fc d\u00fc\u011fme", "button_4": "D\u00f6rd\u00fcnc\u00fc d\u00fc\u011fme", + "button_5": "Be\u015finci d\u00fc\u011fme", + "button_6": "Alt\u0131nc\u0131 d\u00fc\u011fme", + "button_7": "Yedinci d\u00fc\u011fme", "close_1": "Kapat 1", "close_2": "Kapat 2", "close_3": "Kapat 3", diff --git a/homeassistant/components/lutron_caseta/translations/zh-Hant.json b/homeassistant/components/lutron_caseta/translations/zh-Hant.json index 320c26fd2ea8ba0eec7dde839dc684ecf3747e8e..cb2ee04c9486910336202f57ef798956bdef61b6 100644 --- a/homeassistant/components/lutron_caseta/translations/zh-Hant.json +++ b/homeassistant/components/lutron_caseta/translations/zh-Hant.json @@ -33,6 +33,9 @@ "button_2": "\u7b2c\u4e8c\u500b\u6309\u9215", "button_3": "\u7b2c\u4e09\u500b\u6309\u9215", "button_4": "\u7b2c\u56db\u500b\u6309\u9215", + "button_5": "\u7b2c\u4e94\u500b\u6309\u9215", + "button_6": "\u7b2c\u516d\u500b\u6309\u9215", + "button_7": "\u7b2c\u4e03\u500b\u6309\u9215", "close_1": "\u95dc\u9589 1", "close_2": "\u95dc\u9589 2", "close_3": "\u95dc\u9589 3", diff --git a/homeassistant/components/luxaflex/manifest.json b/homeassistant/components/luxaflex/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..57c552c616a28f9c0f8f05a7cac5499db5dff33e --- /dev/null +++ b/homeassistant/components/luxaflex/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "luxaflex", + "name": "Luxaflex", + "integration_type": "virtual", + "supported_by": "hunterdouglas_powerview" +} diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index d727b24eee49d2f1e8b10f74a3fdb7da52a3c956..4d132381d42fb15455c4498a0a3ad55675868d22 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -16,6 +16,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -114,7 +115,7 @@ async def async_setup_entry( name="Outdoor Humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="%", + native_unit_of_measurement=PERCENTAGE, value=lambda device: device.displayedOutdoorHumidity, ), location, diff --git a/homeassistant/components/lyric/translations/bg.json b/homeassistant/components/lyric/translations/bg.json index 2f756377e31b9126ea46c9a4858bdbea4d87e675..5d9459cac2c02b53c1b106646c32487d5ce1dc7d 100644 --- a/homeassistant/components/lyric/translations/bg.json +++ b/homeassistant/components/lyric/translations/bg.json @@ -1,7 +1,15 @@ { "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" + }, "create_entry": { "default": "\u0423\u0441\u043f\u0435\u0448\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "step": { + "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/lyric/translations/id.json b/homeassistant/components/lyric/translations/id.json index b75093d171891cd92edf6f6d5e0787806a3aea8a..4778e097c93a32b4b27b9b7e0a48c6a12d99b96b 100644 --- a/homeassistant/components/lyric/translations/id.json +++ b/homeassistant/components/lyric/translations/id.json @@ -20,8 +20,8 @@ }, "issues": { "removed_yaml": { - "description": "Proses konfigurasi Honeywell Lyric lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Honeywell Lyric telah dihapus" + "description": "Proses konfigurasi Integrasi Honeywell Lyric lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Honeywell Lyric telah dihapus" } } } \ No newline at end of file diff --git a/homeassistant/components/lyric/translations/no.json b/homeassistant/components/lyric/translations/no.json index e41e3a0c58123bcc3c060aab08405738dd21da3a..1f9ff8bd73715b3e13eeb8bdc6832105ce46be25 100644 --- a/homeassistant/components/lyric/translations/no.json +++ b/homeassistant/components/lyric/translations/no.json @@ -3,7 +3,7 @@ "abort": { "authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse", "missing_configuration": "Komponenten er ikke konfigurert, vennligst f\u00f8lg dokumentasjonen", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "create_entry": { "default": "Vellykket godkjenning" diff --git a/homeassistant/components/magicseaweed/sensor.py b/homeassistant/components/magicseaweed/sensor.py index b0b2e92ada559a520a393bd70fe1545eb9eba395..79b77a362c42a26ee00fd0d0ac5e12f412e58326 100644 --- a/homeassistant/components/magicseaweed/sensor.py +++ b/homeassistant/components/magicseaweed/sensor.py @@ -12,18 +12,14 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_API_KEY, - CONF_MONITORED_CONDITIONS, - CONF_NAME, -) +from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME 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 homeassistant.util import Throttle import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import METRIC_SYSTEM _LOGGER = logging.getLogger(__name__) @@ -33,7 +29,6 @@ CONF_UNITS = "units" DEFAULT_UNIT = "us" DEFAULT_NAME = "MSW" -DEFAULT_ATTRIBUTION = "Data provided by magicseaweed.com" ICON = "mdi:waves" @@ -92,7 +87,7 @@ def setup_platform( if CONF_UNITS in config: units = config.get(CONF_UNITS) - elif hass.config.units.is_metric: + elif hass.config.units is METRIC_SYSTEM: units = UNITS[0] else: units = UNITS[2] @@ -126,6 +121,7 @@ def setup_platform( class MagicSeaweedSensor(SensorEntity): """Implementation of a MagicSeaweed sensor.""" + _attr_attribution = "Data provided by magicseaweed.com" _attr_icon = ICON def __init__( @@ -150,7 +146,7 @@ class MagicSeaweedSensor(SensorEntity): else: self._attr_name = f"{hour} {name} {description.name}" - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_extra_state_attributes = {} @property def unit_system(self): diff --git a/homeassistant/components/map/manifest.json b/homeassistant/components/map/manifest.json index f78dcfd20ba36c2d07d31dd6c91dbe03cc26cce2..ed45ab069fa6b86c318ddb425696000cc0a03266 100644 --- a/homeassistant/components/map/manifest.json +++ b/homeassistant/components/map/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/map", "dependencies": ["frontend"], "codeowners": [], - "quality_scale": "internal" + "quality_scale": "internal", + "integration_type": "system" } diff --git a/homeassistant/components/marantz/manifest.json b/homeassistant/components/marantz/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..00a9b0675ec8f51ea336a074f4b4ba6e83739d4a --- /dev/null +++ b/homeassistant/components/marantz/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "marantz", + "name": "Marantz", + "integration_type": "virtual", + "supported_by": "denonavr" +} diff --git a/homeassistant/components/martec/manifest.json b/homeassistant/components/martec/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..67402e4722abe84119d083a9b694e67e9374b3f2 --- /dev/null +++ b/homeassistant/components/martec/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "martec", + "name": "Martec", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/maxcube/binary_sensor.py b/homeassistant/components/maxcube/binary_sensor.py index c6c9ba8ebb0d6acfed963ca78b4c500c3d32b530..051ef02ed255b634818fa5e803ce8eabf0beb4ea 100644 --- a/homeassistant/components/maxcube/binary_sensor.py +++ b/homeassistant/components/maxcube/binary_sensor.py @@ -28,8 +28,7 @@ def setup_platform( if device.is_windowshutter(): devices.append(MaxCubeShutter(handler, device)) - if devices: - add_entities(devices) + add_entities(devices) class MaxCubeBinarySensorBase(BinarySensorEntity): diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index 6361600518be10fba0ae27cba3cb71423321e686..733736606f62eaaac3ca2661ff5bba89c6225851 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -60,8 +60,7 @@ def setup_platform( if device.is_thermostat() or device.is_wallthermostat(): devices.append(MaxCubeClimate(handler, device)) - if devices: - add_entities(devices) + add_entities(devices) class MaxCubeClimate(ClimateEntity): diff --git a/homeassistant/components/mazda/sensor.py b/homeassistant/components/mazda/sensor.py index 715b274b6f5a2fceac4b176d3de968526a72bbd9..212d646051c4c9a9c44cb355d8393e18cb4eabe7 100644 --- a/homeassistant/components/mazda/sensor.py +++ b/homeassistant/components/mazda/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_KILOMETERS, LENGTH_MILES, PERCENTAGE, @@ -22,7 +21,7 @@ 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.unit_system import UnitSystem +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem from . import MazdaEntity from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN @@ -50,9 +49,9 @@ class MazdaSensorEntityDescription( unit: Callable[[UnitSystem], str | None] | None = None -def _get_distance_unit(unit_system): +def _get_distance_unit(unit_system: UnitSystem) -> str: """Return the distance unit for the given unit system.""" - if unit_system.name == CONF_UNIT_SYSTEM_IMPERIAL: + if unit_system is US_CUSTOMARY_SYSTEM: return LENGTH_MILES return LENGTH_KILOMETERS diff --git a/homeassistant/components/mazda/translations/bg.json b/homeassistant/components/mazda/translations/bg.json index 6e9ce8d9a6a2dded48418449188da69d6309769f..1eb89184642accbbdccfe1f5ea77be2080d5521e 100644 --- a/homeassistant/components/mazda/translations/bg.json +++ b/homeassistant/components/mazda/translations/bg.json @@ -7,7 +7,7 @@ "step": { "user": { "data": { - "email": "Email", + "email": "\u0418\u043c\u0435\u0439\u043b", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "region": "\u0420\u0435\u0433\u0438\u043e\u043d" } diff --git a/homeassistant/components/mazda/translations/nb.json b/homeassistant/components/mazda/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/mazda/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mazda/translations/no.json b/homeassistant/components/mazda/translations/no.json index 875e21e8c04222773dbbf40599a23f082f150542..567e1741a789125c40240e4ac1dcc021af7f76c3 100644 --- a/homeassistant/components/mazda/translations/no.json +++ b/homeassistant/components/mazda/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "account_locked": "Kontoen er l\u00e5st. Pr\u00f8v igjen senere.", diff --git a/homeassistant/components/meater/translations/nb.json b/homeassistant/components/meater/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..9188eb3a336e4ac0fc37f76d2c0c3af5a3010821 --- /dev/null +++ b/homeassistant/components/meater/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown_auth_error": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/media_player/translations/pl.json b/homeassistant/components/media_player/translations/pl.json index 886cb07f93fd73944e5e2b333509a44ce8021e30..8eb1accf33af2ffc28c06bb9d4d3f939e6daaff6 100644 --- a/homeassistant/components/media_player/translations/pl.json +++ b/homeassistant/components/media_player/translations/pl.json @@ -20,7 +20,7 @@ }, "state": { "_": { - "buffering": "Buforowanie", + "buffering": "buforowanie", "idle": "nieaktywny", "off": "wy\u0142.", "on": "w\u0142.", diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 47a5d7f696981fe09222c97f9ead8db97c64eb4c..21c32c9137fee4f4fbf84428fff645d24eb5645c 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -163,7 +163,7 @@ async def async_resolve_media( ) @websocket_api.async_response async def websocket_browse_media( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Browse available media.""" try: @@ -185,7 +185,7 @@ async def websocket_browse_media( ) @websocket_api.async_response async def websocket_resolve_media( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Resolve media.""" try: diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 1ec3d6f9462f3a985e99fcae00760147ba96da2c..54f82365ffb251d55c0c52dfb934d75968aaa6ac 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -5,6 +5,7 @@ import logging import mimetypes from pathlib import Path import shutil +from typing import Any from aiohttp import web from aiohttp.web_request import FileField @@ -323,7 +324,7 @@ class UploadMediaView(http.HomeAssistantView): @websocket_api.require_admin @websocket_api.async_response async def websocket_remove_media( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Remove media.""" try: diff --git a/homeassistant/components/melcloud/translations/bg.json b/homeassistant/components/melcloud/translations/bg.json index 102f5304f602bae5885d3083dc6d9625cc4ad439..5c098daefa89e846868e703a10b13b696b723f5d 100644 --- a/homeassistant/components/melcloud/translations/bg.json +++ b/homeassistant/components/melcloud/translations/bg.json @@ -9,7 +9,7 @@ "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "Email" + "username": "\u0418\u043c\u0435\u0439\u043b" }, "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 MELCloud" } diff --git a/homeassistant/components/melcloud/translations/nb.json b/homeassistant/components/melcloud/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/melcloud/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 2857c05748217d52653f8163a5050cfdd9750773..fc79ac4c60fac2074cf2e53e308cac38b0866e88 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -26,6 +26,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter +from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( CONF_TRACK_HOME, @@ -95,7 +96,7 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): """Initialize global Met data updater.""" self._unsub_track_home: Callable[[], None] | None = None self.weather = MetWeatherData( - hass, config_entry.data, hass.config.units.is_metric + hass, config_entry.data, hass.config.units is METRIC_SYSTEM ) self.weather.set_coordinates() diff --git a/homeassistant/components/met/translations/bg.json b/homeassistant/components/met/translations/bg.json index ee2071403c491c92f8ad07d347424436c650cad1..cf70857a4155cf291bb8fe5448a531ac1e3a0c22 100644 --- a/homeassistant/components/met/translations/bg.json +++ b/homeassistant/components/met/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "no_home": "\u0412 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 Home Assistant \u043d\u0435 \u0441\u0430 \u0437\u0430\u0434\u0430\u0434\u0435\u043d\u0438 \u0434\u043e\u043c\u0430\u0448\u043d\u0438 \u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u0438" + }, "error": { "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" }, diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index c843be73fe7b1a93bba7d3b34290e80c759493bd..2aa70929795cd5f864926bf5d4597b5f7931e5f5 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -30,6 +30,7 @@ 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.unit_system import METRIC_SYSTEM from . import MetDataUpdateCoordinator from .const import ATTR_MAP, CONDITIONS_MAP, CONF_TRACK_HOME, DOMAIN, FORECAST_MAP @@ -51,10 +52,13 @@ async def async_setup_entry( async_add_entities( [ MetWeather( - coordinator, config_entry.data, hass.config.units.is_metric, False + coordinator, + config_entry.data, + hass.config.units is METRIC_SYSTEM, + False, ), MetWeather( - coordinator, config_entry.data, hass.config.units.is_metric, True + coordinator, config_entry.data, hass.config.units is METRIC_SYSTEM, True ), ] ) diff --git a/homeassistant/components/met_eireann/translations/bg.json b/homeassistant/components/met_eireann/translations/bg.json index 2c39cd06b7da8922f97255f290bb685d1ebebfbb..9f826873b7b07ba9ee4ed4026e918e5281b24569 100644 --- a/homeassistant/components/met_eireann/translations/bg.json +++ b/homeassistant/components/met_eireann/translations/bg.json @@ -1,8 +1,13 @@ { "config": { + "error": { + "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": { + "elevation": "\u041d\u0430\u0434\u043c\u043e\u0440\u0441\u043a\u0430 \u0432\u0438\u0441\u043e\u0447\u0438\u043d\u0430", + "latitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0448\u0438\u0440\u0438\u043d\u0430", "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430", "name": "\u0418\u043c\u0435" } diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index cfdd62933c02bd27b4b999c13e69767bafdd0680..5a88275ba6ad0cb404d28d5f7a24d11c5d153d28 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -3,7 +3,7 @@ "name": "M\u00e9t\u00e9o-France", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/meteo_france", - "requirements": ["meteofrance-api==1.0.2"], + "requirements": ["meteofrance-api==1.1.0"], "codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"], "iot_class": "cloud_polling", "loggers": ["meteofrance_api"] diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 823018f405f5362a70c98186a13abdc986de745b..b3a0ad498d942b54939570bdf85f1c863d78628a 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -8,7 +8,6 @@ from meteofrance_api.helpers import ( from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -81,6 +80,7 @@ class MeteoFranceSensor(CoordinatorEntity, SensorEntity): """Representation of a Meteo-France sensor.""" entity_description: MeteoFranceSensorEntityDescription + _attr_attribution = ATTRIBUTION def __init__( self, @@ -94,7 +94,6 @@ class MeteoFranceSensor(CoordinatorEntity, SensorEntity): city_name = coordinator.data.position["name"] self._attr_name = f"{city_name} {description.name}" self._attr_unique_id = f"{coordinator.data.position['lat']},{coordinator.data.position['lon']}_{description.key}" - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} @property def device_info(self) -> DeviceInfo: @@ -162,7 +161,6 @@ class MeteoFranceRainSensor(MeteoFranceSensor): f"{int((item['dt'] - reference_dt) / 60)} min": item["desc"] for item in self.coordinator.data.forecast }, - ATTR_ATTRIBUTION: ATTRIBUTION, } @@ -192,7 +190,6 @@ class MeteoFranceAlertSensor(MeteoFranceSensor): """Return the state attributes.""" return { **readeable_phenomenoms_dict(self.coordinator.data.phenomenons_max_colors), - ATTR_ATTRIBUTION: ATTRIBUTION, } diff --git a/homeassistant/components/meteo_france/translations/nb.json b/homeassistant/components/meteo_france/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/meteo_france/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meteoclimatic/translations/nb.json b/homeassistant/components/meteoclimatic/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/meteoclimatic/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 77532b379b62975c0e3dd7447010ece7df23965e..ef9643be96ae053d326a297fb8c6eaac571f8c3c 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, LENGTH_KILOMETERS, PERCENTAGE, SPEED_MILES_PER_HOUR, @@ -84,6 +83,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="wind_speed", name="Wind speed", native_unit_of_measurement=SPEED_MILES_PER_HOUR, + suggested_unit_of_measurement=SPEED_MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, icon="mdi:weather-windy", entity_registry_enabled_default=True, @@ -99,6 +99,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="wind_gust", name="Wind gust", native_unit_of_measurement=SPEED_MILES_PER_HOUR, + suggested_unit_of_measurement=SPEED_MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, icon="mdi:weather-windy", entity_registry_enabled_default=False, @@ -180,6 +181,7 @@ class MetOfficeCurrentSensor( ): """Implementation of a Met Office current weather condition sensor.""" + _attr_attribution = ATTRIBUTION _attr_has_entity_name = True def __init__( @@ -258,7 +260,6 @@ class MetOfficeCurrentSensor( def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the device.""" return { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_LAST_UPDATE: self.coordinator.data.now.date, ATTR_SENSOR_ID: self.entity_description.key, ATTR_SITE_ID: self.coordinator.data.site.id, diff --git a/homeassistant/components/metoffice/translations/nb.json b/homeassistant/components/metoffice/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/metoffice/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 6a158c60fcfafb9700dbc8feeef5cd6d8ad4acd8..6c98c389984e6e43fd9dfc69ce578e6a1f352d8b 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -2,7 +2,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ATTR_MANUFACTURER, DOMAIN @@ -20,8 +20,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b api = await hass.async_add_executor_job(get_api, dict(config_entry.data)) except CannotConnect as api_error: raise ConfigEntryNotReady from api_error - except LoginError: - return False + except LoginError as err: + raise ConfigEntryAuthFailed from err coordinator = MikrotikDataUpdateCoordinator(hass, config_entry, api) await hass.async_add_executor_job(coordinator.api.get_hub_details) diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index ed62734578f3d748f04dc4028c1a7c24ef0fc3d7..84b334c5f8fa0a5824897645358684cc5a957a32 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Mikrotik.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any import voluptuous as vol @@ -33,6 +34,7 @@ class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Mikrotik config flow.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None @staticmethod @callback @@ -76,6 +78,49 @@ class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth(self, 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 self.hass.async_add_executor_job(get_api, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except LoginError: + errors[CONF_PASSWORD] = "invalid_auth" + + if not errors: + 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 MikrotikOptionsFlowHandler(config_entries.OptionsFlow): """Handle Mikrotik options.""" diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 9529322affd2bac39825a75f333e14dd255ec521..cfa2e216d1d4c41d941dcbc61d36a05cdccc99da 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -68,8 +68,7 @@ def update_items( tracked[mac] = MikrotikDataUpdateCoordinatorTracker(device, coordinator) new_tracked.append(tracked[mac]) - if new_tracked: - async_add_entities(new_tracked) + async_add_entities(new_tracked) class MikrotikDataUpdateCoordinatorTracker( diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 08320c603f9118701617cb4040e2b86e5e9f32cd..26a589486206e64fb798901d6f2a915cea7a0da1 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -13,6 +13,7 @@ from librouteros.login import plain as login_plain, token as login_token from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -132,8 +133,10 @@ class MikrotikData: # get new hub firmware version if updated self.firmware = self.get_info(ATTR_FIRMWARE) - except (CannotConnect, LoginError) as err: + except CannotConnect as err: raise UpdateFailed from err + except LoginError as err: + raise ConfigEntryAuthFailed from err if not device_list: return diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index 6d421cb183827f2d81a9c689c91e2be3213a9e49..ec47d98b7a9f5ed52a7f6d0d203096201e408a52 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -11,6 +11,13 @@ "port": "[%key:common::config_flow::data::port%]", "verify_ssl": "Use ssl" } + }, + "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": { @@ -19,7 +26,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/homeassistant/components/mikrotik/translations/bg.json b/homeassistant/components/mikrotik/translations/bg.json index d81e97f2d68162687204103f70ea56723091b666..3316c8f5a6cbe7135b0f21e13ff63fa0e7c3fc28 100644 --- a/homeassistant/components/mikrotik/translations/bg.json +++ b/homeassistant/components/mikrotik/translations/bg.json @@ -1,13 +1,21 @@ { "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" + "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", + "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", "name_exists": "\u0418\u043c\u0435\u0442\u043e \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + }, + "description": "\u041f\u0430\u0440\u043e\u043b\u0430\u0442\u0430 \u0437\u0430 {username} \u0435 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0430.", + "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" + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/mikrotik/translations/ca.json b/homeassistant/components/mikrotik/translations/ca.json index f7adef2f885a36beb515ee9ae615db44bf7def7d..41bafc4d32f8830b1ec9b5b26e69ff2e80c7e4aa 100644 --- a/homeassistant/components/mikrotik/translations/ca.json +++ b/homeassistant/components/mikrotik/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": "El nom 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/mikrotik/translations/de.json b/homeassistant/components/mikrotik/translations/de.json index 1a9c3b5d352673479c5046bde13f9fb9662a29c2..6ecdf66989f2eec08d5d68b3cc71e1424b76df9a 100644 --- a/homeassistant/components/mikrotik/translations/de.json +++ b/homeassistant/components/mikrotik/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 vorhanden" }, "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/mikrotik/translations/el.json b/homeassistant/components/mikrotik/translations/el.json index 4faca6d756e2be118ba0f770b38a2f72f9b07d5b..4dfffe989325681c6f4aac6e22617ee933431aa3 100644 --- a/homeassistant/components/mikrotik/translations/el.json +++ b/homeassistant/components/mikrotik/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 \u03cc\u03bd\u03bf\u03bc\u03b1 \u03c5\u03c0\u03ac\u03c1\u03c7\u03b5\u03b9" }, "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/mikrotik/translations/en.json b/homeassistant/components/mikrotik/translations/en.json index d60a7064e3af1ae966757f8173e5de9a720045d6..9874ed21ff168796a83e3a3197d0e20400625afa 100644 --- a/homeassistant/components/mikrotik/translations/en.json +++ b/homeassistant/components/mikrotik/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 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/mikrotik/translations/es.json b/homeassistant/components/mikrotik/translations/es.json index 13bea3f0be060301b2ff2a419839045d3b76ff8b..66b5c626a5c3e63ed9877bdbb2da8f5156a4ffa2 100644 --- a/homeassistant/components/mikrotik/translations/es.json +++ b/homeassistant/components/mikrotik/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 autenticaci\u00f3n se volvi\u00f3 a realizar correctamente" }, "error": { "cannot_connect": "No se pudo conectar", @@ -9,6 +10,13 @@ "name_exists": "El nombre existe" }, "step": { + "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a" + }, + "description": "La contrase\u00f1a para {username} no es v\u00e1lida.", + "title": "Volver a autenticar la integraci\u00f3n" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/mikrotik/translations/et.json b/homeassistant/components/mikrotik/translations/et.json index bdd6c393b0c5bc6a2f3e4cf97dc00df2652dd8e4..fc7c8dfb4e403b0833cd3658e7c2f156c0ca826d 100644 --- a/homeassistant/components/mikrotik/translations/et.json +++ b/homeassistant/components/mikrotik/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/mikrotik/translations/fr.json b/homeassistant/components/mikrotik/translations/fr.json index 5d0f2786effc5de6254385c2235fc00bc04f152a..a627d3c15e3749d29c74edf18b5551f225eb382d 100644 --- a/homeassistant/components/mikrotik/translations/fr.json +++ b/homeassistant/components/mikrotik/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": "Le nom existe" }, "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/mikrotik/translations/he.json b/homeassistant/components/mikrotik/translations/he.json index bef2f812e0f1855e71d3c91ce1a2e7c6b4e3644e..5ea9d3200a886140f8f2b9f61f74358985b7a4b1 100644 --- a/homeassistant/components/mikrotik/translations/he.json +++ b/homeassistant/components/mikrotik/translations/he.json @@ -1,13 +1,20 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "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" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + }, + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7", diff --git a/homeassistant/components/mikrotik/translations/hu.json b/homeassistant/components/mikrotik/translations/hu.json index c15ba2f07aaa0315d4a93de7cb2a4b6043391090..b224f163b64b2ce8fcfd371ca624d37fc4f4a3bf 100644 --- a/homeassistant/components/mikrotik/translations/hu.json +++ b/homeassistant/components/mikrotik/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 l\u00e9tezik" }, "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/mikrotik/translations/id.json b/homeassistant/components/mikrotik/translations/id.json index 3ef0dacb763217aa5456c4561f8778e9ff6c8558..e6166ef9ed2c53b6ff9f34e0c64936fa9a81df47 100644 --- a/homeassistant/components/mikrotik/translations/id.json +++ b/homeassistant/components/mikrotik/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/mikrotik/translations/it.json b/homeassistant/components/mikrotik/translations/it.json index 203b13ff2d3b634222c97c0c4ed320710d048b9b..297d9728b701fa3ccd7e0c15fa5f7506879f9093 100644 --- a/homeassistant/components/mikrotik/translations/it.json +++ b/homeassistant/components/mikrotik/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": "Il Nome esiste gi\u00e0" }, "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/mikrotik/translations/ja.json b/homeassistant/components/mikrotik/translations/ja.json index 93cde1c8391f47164d71c7767531938e575481cc..27296d92e45e5d8d91c7bab472af721880b4baa3 100644 --- a/homeassistant/components/mikrotik/translations/ja.json +++ b/homeassistant/components/mikrotik/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\u304c\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": "\u7d71\u5408\u306e\u518d\u8a8d\u8a3c" + }, "user": { "data": { "host": "\u30db\u30b9\u30c8", diff --git a/homeassistant/components/mikrotik/translations/nb.json b/homeassistant/components/mikrotik/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..204b2dfd9336c53215c27b8dd007b4d08447ac74 --- /dev/null +++ b/homeassistant/components/mikrotik/translations/nb.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "password": "Passord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/translations/nl.json b/homeassistant/components/mikrotik/translations/nl.json index 78e143ddadbb314296023df58a4dd38eb78b4dd8..a85a272b457f7145de82f91079af154608e1cc36 100644 --- a/homeassistant/components/mikrotik/translations/nl.json +++ b/homeassistant/components/mikrotik/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/mikrotik/translations/no.json b/homeassistant/components/mikrotik/translations/no.json index 73efde429fe0b496b889caa8b66272080803c5a9..c39ce4becfc0c08554a1059a132dabbb4467fd40 100644 --- a/homeassistant/components/mikrotik/translations/no.json +++ b/homeassistant/components/mikrotik/translations/no.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Enheten er allerede konfigurert" + "already_configured": "Enheten er allerede konfigurert", + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", @@ -9,6 +10,13 @@ "name_exists": "Navnet eksisterer" }, "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/mikrotik/translations/pl.json b/homeassistant/components/mikrotik/translations/pl.json index 8f056e29de6bbe7201eb0d85886910f73d9daa13..f4231bb767f0d6774fb47ab5eb0c3543f5f93445 100644 --- a/homeassistant/components/mikrotik/translations/pl.json +++ b/homeassistant/components/mikrotik/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 u\u017cytkownika {username} jest nieprawid\u0142owe.", + "title": "Ponownie uwierzytelnij integracj\u0119" + }, "user": { "data": { "host": "Nazwa hosta lub adres IP", diff --git a/homeassistant/components/mikrotik/translations/pt-BR.json b/homeassistant/components/mikrotik/translations/pt-BR.json index 0fb66a063bddd7cb83ee5c1dd3d2d59321897a91..923ebc2680609e59fccdd0ebad8f63926cac6ff2 100644 --- a/homeassistant/components/mikrotik/translations/pt-BR.json +++ b/homeassistant/components/mikrotik/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": "A reautentica\u00e7\u00e3o foi bem-sucedida" }, "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 {username} \u00e9 inv\u00e1lida.", + "title": "Reautenticar Integra\u00e7\u00e3o" + }, "user": { "data": { "host": "Nome do host", diff --git a/homeassistant/components/mikrotik/translations/ru.json b/homeassistant/components/mikrotik/translations/ru.json index 015d2061c76f7c77fd1650d48ea6ef8ed61ae0bc..2c72c3f4aa18f19342799e3fd296751dc5c540c6 100644 --- a/homeassistant/components/mikrotik/translations/ru.json +++ b/homeassistant/components/mikrotik/translations/ru.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", @@ -9,6 +10,13 @@ "name_exists": "\u042d\u0442\u043e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f." }, "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f {username}.", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/mikrotik/translations/sv.json b/homeassistant/components/mikrotik/translations/sv.json index bc93490db14c63fab15d018752b707cab4abd446..1dd1d00c0c06ffe1db9a6e215919bbe9408e5a45 100644 --- a/homeassistant/components/mikrotik/translations/sv.json +++ b/homeassistant/components/mikrotik/translations/sv.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Mikrotik \u00e4r redan konfigurerad" + "already_configured": "Mikrotik \u00e4r redan konfigurerad", + "reauth_successful": "\u00c5terautentisering lyckades" }, "error": { "cannot_connect": "Anslutningen misslyckades", @@ -9,6 +10,13 @@ "name_exists": "Namnet finns" }, "step": { + "reauth_confirm": { + "data": { + "password": "L\u00f6senord" + }, + "description": "L\u00f6senordet f\u00f6r {username} \u00e4r ogiltigt.", + "title": "\u00c5terautenticera integration" + }, "user": { "data": { "host": "V\u00e4rd", diff --git a/homeassistant/components/mikrotik/translations/tr.json b/homeassistant/components/mikrotik/translations/tr.json index 628703168ec5951cf22eefd37ab56076d6abd5f9..bfbdad17280c9ab46b58c79c111bcf44c9c6972f 100644 --- a/homeassistant/components/mikrotik/translations/tr.json +++ b/homeassistant/components/mikrotik/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": "Bu 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/mikrotik/translations/zh-Hans.json b/homeassistant/components/mikrotik/translations/zh-Hans.json index 14916be12643afd059285c60008c7fe2abb18a01..fee7fc43e78886d3539a7414d6edb16fc62fdb09 100644 --- a/homeassistant/components/mikrotik/translations/zh-Hans.json +++ b/homeassistant/components/mikrotik/translations/zh-Hans.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e" + "already_configured": "\u8bbe\u5907\u5df2\u88ab\u914d\u7f6e", + "reauth_successful": "\u91cd\u65b0\u8ba4\u8bc1\u6210\u529f" }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", @@ -9,6 +10,13 @@ "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "{username} \u7684\u5bc6\u7801\u5df2\u5931\u6548\u3002", + "title": "\u91cd\u65b0\u8ba4\u8bc1\u96c6\u6210" + }, "user": { "data": { "host": "\u4e3b\u673a", diff --git a/homeassistant/components/mikrotik/translations/zh-Hant.json b/homeassistant/components/mikrotik/translations/zh-Hant.json index 3872814e417bb428b108b3f5de34248b8cd91aee..d77f3c51dff1cd0b64f98957013ca8077076063c 100644 --- a/homeassistant/components/mikrotik/translations/zh-Hant.json +++ b/homeassistant/components/mikrotik/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/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index 0f53875861d14f168e6bbc5ae125d394b1642048..87edcd3876601df8b8deeb532afb18bc45dfb492 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, + CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -61,6 +62,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_ENTITY_IDS): cv.entity_ids, vol.Optional(CONF_ROUND_DIGITS, default=2): vol.Coerce(int), + vol.Optional(CONF_UNIQUE_ID): str, } ) @@ -102,11 +104,12 @@ async def async_setup_platform( name = config.get(CONF_NAME) sensor_type = config.get(CONF_TYPE) round_digits = config.get(CONF_ROUND_DIGITS) + unique_id = config.get(CONF_UNIQUE_ID) await async_setup_reload_service(hass, DOMAIN, PLATFORMS) async_add_entities( - [MinMaxSensor(entity_ids, name, sensor_type, round_digits, None)] + [MinMaxSensor(entity_ids, name, sensor_type, round_digits, unique_id)] ) diff --git a/homeassistant/components/moat/translations/hu.json b/homeassistant/components/moat/translations/hu.json index 7ef0d3a63013dc9a7c1814fe3c80d99ab7dede60..e1673194c6d885ee4f8bae1faa811bd0f14444f2 100644 --- a/homeassistant/components/moat/translations/hu.json +++ b/homeassistant/components/moat/translations/hu.json @@ -14,7 +14,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/mobile_app/websocket_api.py b/homeassistant/components/mobile_app/websocket_api.py index 4b0863d77af20b385c87a21a2717e73aeac928b7..c900aa8f93b49b3a6a92d5fa7107a4efff4be6cd 100644 --- a/homeassistant/components/mobile_app/websocket_api.py +++ b/homeassistant/components/mobile_app/websocket_api.py @@ -2,11 +2,12 @@ from __future__ import annotations from functools import wraps +from typing import Any import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from .const import CONF_USER_ID, DATA_CONFIG_ENTRIES, DATA_PUSH_CHANNEL, DOMAIN from .push_notification import PushChannel @@ -56,7 +57,11 @@ def _ensure_webhook_access(func): vol.Required("confirm_id"): str, } ) -def handle_push_notification_confirm(hass, connection, msg): +def handle_push_notification_confirm( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Confirm receipt of a push notification.""" channel: PushChannel | None = hass.data[DOMAIN][DATA_PUSH_CHANNEL].get( msg["webhook_id"] @@ -88,7 +93,11 @@ def handle_push_notification_confirm(hass, connection, msg): ) @_ensure_webhook_access @websocket_api.async_response -async def handle_push_notification_channel(hass, connection, msg): +async def handle_push_notification_channel( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Set up a direct push notification channel.""" webhook_id = msg["webhook_id"] registered_channels: dict[str, PushChannel] = hass.data[DOMAIN][DATA_PUSH_CHANNEL] diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 2e855c7af8df9ed96b6ee3a5811f817e2fe056fd..8aa2903506fd16166c98f302e4e1ed355b63a68a 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, ) +from homeassistant.components.climate import HVACMode from homeassistant.components.cover import ( DEVICE_CLASSES_SCHEMA as COVER_DEVICE_CLASSES_SCHEMA, ) @@ -65,6 +66,9 @@ from .const import ( # noqa: F401 CONF_DATA_TYPE, CONF_FANS, CONF_HUB, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, + CONF_HVAC_ONOFF_REGISTER, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_MAX_TEMP, @@ -218,6 +222,21 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_int, vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, + vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, + vol.Optional(CONF_HVAC_MODE_REGISTER): vol.Maybe( + { + CONF_ADDRESS: cv.positive_int, + CONF_HVAC_MODE_VALUES: { + vol.Optional(HVACMode.OFF.value): cv.positive_int, + vol.Optional(HVACMode.HEAT.value): cv.positive_int, + vol.Optional(HVACMode.COOL.value): cv.positive_int, + vol.Optional(HVACMode.HEAT_COOL.value): cv.positive_int, + vol.Optional(HVACMode.AUTO.value): cv.positive_int, + vol.Optional(HVACMode.DRY.value): cv.positive_int, + vol.Optional(HVACMode.FAN_ONLY.value): cv.positive_int, + }, + } + ), } ), ) @@ -268,7 +287,7 @@ BINARY_SENSOR_SCHEMA = BASE_COMPONENT_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_INPUT_TYPE, default=CALL_TYPE_COIL): vol.In( - [CALL_TYPE_COIL, CALL_TYPE_DISCRETE] + [CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING] ), vol.Optional(CONF_SLAVE_COUNT, default=0): cv.positive_int, } diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index c432c1024929b941107eb839e9daa5b5178c279b..ed2f6fa022715db39359184d17e51b8bf757472f 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import ( from . import get_hub from .base_platform import BasePlatform -from .const import CONF_SLAVE_COUNT +from .const import CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CONF_SLAVE_COUNT from .modbus import ModbusHub _LOGGER = logging.getLogger(__name__) @@ -109,9 +109,12 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): self._result = None else: self._lazy_errors = self._lazy_error_count - self._attr_is_on = result.bits[0] & 1 self._attr_available = True self._result = result + if self._input_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): + self._attr_is_on = bool(result.bits[0] & 1) + else: + self._attr_is_on = bool(result.registers[0] & 1) self.async_write_ha_state() if self._coordinator: diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 7d6376a5a42e09223374b652b6aa1e8f006e1e07..92efcfb17d58089cbc00a911f44a65c5425a9104 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime import struct -from typing import Any +from typing import Any, cast from homeassistant.components.climate import ( ClimateEntity, @@ -12,6 +12,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ( ATTR_TEMPERATURE, + CONF_ADDRESS, CONF_NAME, CONF_TEMPERATURE_UNIT, PRECISION_TENTHS, @@ -31,6 +32,9 @@ from .const import ( CALL_TYPE_WRITE_REGISTER, CALL_TYPE_WRITE_REGISTERS, CONF_CLIMATES, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, + CONF_HVAC_ONOFF_REGISTER, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_STEP, @@ -63,8 +67,6 @@ async def async_setup_platform( class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): """Representation of a Modbus Thermostat.""" - _attr_hvac_mode = HVACMode.AUTO - _attr_hvac_modes = [HVACMode.AUTO] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE def __init__( @@ -90,6 +92,33 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_target_temperature_step = config[CONF_TARGET_TEMP] self._attr_target_temperature_step = config[CONF_STEP] + if CONF_HVAC_MODE_REGISTER in config: + mode_config = config[CONF_HVAC_MODE_REGISTER] + self._hvac_mode_register = mode_config[CONF_ADDRESS] + self._attr_hvac_modes = cast(list[HVACMode], []) + self._attr_hvac_mode = None + self._hvac_mode_mapping: list[tuple[int, HVACMode]] = [] + mode_value_config = mode_config[CONF_HVAC_MODE_VALUES] + for hvac_mode in HVACMode: + if hvac_mode.value in mode_value_config: + self._hvac_mode_mapping.append( + (mode_value_config[hvac_mode.value], hvac_mode) + ) + self._attr_hvac_modes.append(hvac_mode) + + else: + # No HVAC modes defined + self._hvac_mode_register = None + self._attr_hvac_mode = HVACMode.AUTO + self._attr_hvac_modes = [HVACMode.AUTO] + + if CONF_HVAC_ONOFF_REGISTER in config: + self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes.append(HVACMode.OFF) + else: + self._hvac_onoff_register = None + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() @@ -99,8 +128,28 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - # Home Assistant expects this method. - # We'll keep it here to avoid getting exceptions. + if self._hvac_onoff_register is not None: + # Turn HVAC Off by writing 0 to the On/Off register, or 1 otherwise. + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_onoff_register, + 0 if hvac_mode == HVACMode.OFF else 1, + CALL_TYPE_WRITE_REGISTER, + ) + + if self._hvac_mode_register is not None: + # Write a value to the mode register for the desired mode. + for value, mode in self._hvac_mode_mapping: + if mode == hvac_mode: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) + break + + await self.async_update() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -158,11 +207,36 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_current_temperature = await self._async_read_register( self._input_type, self._address ) + + # Read the mode register if defined + if self._hvac_mode_register is not None: + hvac_mode = await self._async_read_register( + CALL_TYPE_REGISTER_HOLDING, self._hvac_mode_register, raw=True + ) + + # Translate the value received + if hvac_mode is not None: + self._attr_hvac_mode = None + for value, mode in self._hvac_mode_mapping: + if hvac_mode == value: + self._attr_hvac_mode = mode + break + + # Read th on/off register if defined. If the value in this + # register is "OFF", it will take precedence over the value + # in the mode register. + if self._hvac_onoff_register is not None: + onoff = await self._async_read_register( + CALL_TYPE_REGISTER_HOLDING, self._hvac_onoff_register, raw=True + ) + if onoff == 0: + self._attr_hvac_mode = HVACMode.OFF + self._call_active = False self.async_write_ha_state() async def _async_read_register( - self, register_type: str, register: int + self, register_type: str, register: int, raw: bool | None = False ) -> float | None: """Read register using the Modbus hub slave.""" result = await self._hub.async_pymodbus_call( @@ -177,6 +251,14 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): return -1 self._lazy_errors = self._lazy_error_count + + if raw: + # Return the raw value read from the register, do not change + # the object's state + self._attr_available = True + return int(result.registers[0]) + + # The regular handling of the value self._value = self.unpack_structure_result(result.registers) if not self._value: self._attr_available = False diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index b09d75f27e00391c94daed28105db3389e834fc9..2ad36f908ce74cf0c3238916a20d8edf41c2ddd9 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -53,6 +53,9 @@ CONF_SWAP_NONE = "none" CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" +CONF_HVAC_MODE_REGISTER = "hvac_mode_register" +CONF_HVAC_MODE_VALUES = "values" +CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_VERIFY = "verify" CONF_VERIFY_REGISTER = "verify_register" CONF_VERIFY_STATE = "verify_state" diff --git a/homeassistant/components/moehlenhoff_alpha2/translations/nb.json b/homeassistant/components/moehlenhoff_alpha2/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/moehlenhoff_alpha2/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 5685e76fac0d6598378d28c7de39b2b04ca4d7fb..f10ddbe1ab00e6fe9b47fa259b5088e671a38965 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -23,6 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_conversion import TemperatureConverter +from homeassistant.util.unit_system import METRIC_SYSTEM _LOGGER = logging.getLogger(__name__) @@ -67,7 +68,7 @@ async def async_setup_platform( [ MoldIndicator( name, - hass.config.units.is_metric, + hass.config.units is METRIC_SYSTEM, indoor_temp_sensor, outdoor_temp_sensor, indoor_humidity_sensor, diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index 2f4a4a33f495ca18b0f5b487ec441a692cd8e4f3..f6150ab1d229be45f39137c35e777976f1c8a90c 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -5,6 +5,7 @@ from serial import SerialException from homeassistant import core from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -27,6 +28,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +MAX_VOLUME = 38 PARALLEL_UPDATES = 1 @@ -114,6 +116,7 @@ async def async_setup_entry( class MonopriceZone(MediaPlayerEntity): """Representation of a Monoprice amplifier zone.""" + _attr_device_class = MediaPlayerDeviceClass.RECEIVER _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET @@ -131,16 +134,18 @@ class MonopriceZone(MediaPlayerEntity): # dict source name -> source_id self._source_name_id = sources[1] # ordered list of all source names - self._source_names = sources[2] + self._attr_source_list = sources[2] self._zone_id = zone_id - self._unique_id = f"{namespace}_{self._zone_id}" - self._name = f"Zone {self._zone_id}" + self._attr_unique_id = f"{namespace}_{self._zone_id}" + self._attr_name = f"Zone {self._zone_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer="Monoprice", + model="6-Zone Amplifier", + name=self.name, + ) self._snapshot = None - self._state = None - self._volume = None - self._source = None - self._mute = None self._update_success = True def update(self) -> None: @@ -156,71 +161,24 @@ class MonopriceZone(MediaPlayerEntity): self._update_success = False return - self._state = MediaPlayerState.ON if state.power else MediaPlayerState.OFF - self._volume = state.volume - self._mute = state.mute + self._attr_state = MediaPlayerState.ON if state.power else MediaPlayerState.OFF + self._attr_volume_level = state.volume / MAX_VOLUME + self._attr_is_volume_muted = state.mute idx = state.source if idx in self._source_id_name: - self._source = self._source_id_name[idx] + self._attr_source = self._source_id_name[idx] else: - self._source = None + self._attr_source = None @property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" return self._zone_id < 20 or self._update_success - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Monoprice", - model="6-Zone Amplifier", - name=self.name, - ) - - @property - def unique_id(self): - """Return unique ID for this device.""" - return self._unique_id - - @property - def name(self): - """Return the name of the zone.""" - return self._name - - @property - def state(self): - """Return the state of the zone.""" - return self._state - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - if self._volume is None: - return None - return self._volume / 38.0 - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._mute - @property def media_title(self): """Return the current source as medial title.""" - return self._source - - @property - def source(self): - """Return the current input source of the device.""" - return self._source - - @property - def source_list(self): - """List of available input sources.""" - return self._source_names + return self.source def snapshot(self): """Save zone's current state.""" @@ -253,16 +211,18 @@ class MonopriceZone(MediaPlayerEntity): def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self._monoprice.set_volume(self._zone_id, int(volume * 38)) + self._monoprice.set_volume(self._zone_id, round(volume * MAX_VOLUME)) def volume_up(self) -> None: """Volume up the media player.""" - if self._volume is None: + if self.volume_level is None: return - self._monoprice.set_volume(self._zone_id, min(self._volume + 1, 38)) + volume = round(self.volume_level * MAX_VOLUME) + self._monoprice.set_volume(self._zone_id, min(volume + 1, MAX_VOLUME)) def volume_down(self) -> None: """Volume down media player.""" - if self._volume is None: + if self.volume_level is None: return - self._monoprice.set_volume(self._zone_id, max(self._volume - 1, 0)) + volume = round(self.volume_level * MAX_VOLUME) + self._monoprice.set_volume(self._zone_id, max(volume - 1, 0)) diff --git a/homeassistant/components/monoprice/translations/nb.json b/homeassistant/components/monoprice/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/monoprice/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/moon/manifest.json b/homeassistant/components/moon/manifest.json index 0402a87cf1a5d03a27098077f7301f715f0fb931..af630a56e2aa1e0b772630802d71d63d0ebd422b 100644 --- a/homeassistant/components/moon/manifest.json +++ b/homeassistant/components/moon/manifest.json @@ -5,5 +5,6 @@ "codeowners": ["@fabaff", "@frenck"], "quality_scale": "internal", "iot_class": "local_polling", + "integration_type": "service", "config_flow": true } diff --git a/homeassistant/components/moon/translations/bg.json b/homeassistant/components/moon/translations/bg.json index 71462a123f9fabf6092d7699548ad8b1f28113b4..47a9a365db1c8deac51424bfb9a0b047f03244d7 100644 --- a/homeassistant/components/moon/translations/bg.json +++ b/homeassistant/components/moon/translations/bg.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435\u0442\u043e \u043d\u0430 Moon \u0441 \u043f\u043e\u043c\u043e\u0449\u0442\u0430 \u043d\u0430 YAML \u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0430\u0442\u043e.\n\n\u0412\u0430\u0448\u0430\u0442\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430 YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0435 \u0441\u0435 \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 \u043e\u0442 Home Assistant.\n\n\u041f\u0440\u0435\u043c\u0430\u0445\u043d\u0435\u0442\u0435 YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043e\u0442 \u0432\u0430\u0448\u0438\u044f \u0444\u0430\u0439\u043b configuration.yaml \u0438 \u0440\u0435\u0441\u0442\u0430\u0440\u0442\u0438\u0440\u0430\u0439\u0442\u0435 Home Assistant, \u0437\u0430 \u0434\u0430 \u043a\u043e\u0440\u0438\u0433\u0438\u0440\u0430\u0442\u0435 \u0442\u043e\u0437\u0438 \u043f\u0440\u043e\u0431\u043b\u0435\u043c.", + "title": "YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 Moon \u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0430\u0442\u0430" + } + }, "title": "\u041b\u0443\u043d\u0430" } \ No newline at end of file diff --git a/homeassistant/components/moon/translations/ca.json b/homeassistant/components/moon/translations/ca.json index 085de62df8c24d58948de89a9d543e136151dddd..ff98a6fea96b5dac7736fae17073563f0ef09a03 100644 --- a/homeassistant/components/moon/translations/ca.json +++ b/homeassistant/components/moon/translations/ca.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "La configuraci\u00f3 de la Lluna mitjan\u00e7ant YAML s'ha eliminat.\n\nHome Assistant ja no utilitza la configuraci\u00f3 YAML existent.\n\nElimina la configuraci\u00f3 YAML corresponent del fitxer configuration.yaml i reinicia Home Assistant per solucionar aquest problema.", + "title": "La configuraci\u00f3 YAML de la Lluna s'ha eliminat" + } + }, "title": "Lluna" } \ No newline at end of file diff --git a/homeassistant/components/moon/translations/el.json b/homeassistant/components/moon/translations/el.json index 51cde4e9c656c12d28caac0fba5e1337d0f2e040..11791927ad2363e09f38f4609a03015f8e81d48b 100644 --- a/homeassistant/components/moon/translations/el.json +++ b/homeassistant/components/moon/translations/el.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "\u0397 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03c4\u03b7\u03c2 Moon \u03bc\u03b5 \u03c7\u03c1\u03ae\u03c3\u03b7 YAML \u03ad\u03c7\u03b5\u03b9 \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03b7\u03b8\u03b5\u03af.\n\n\u0397 \u03c5\u03c0\u03ac\u03c1\u03c7\u03bf\u03c5\u03c3\u03b1 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 YAML \u03b4\u03b5\u03bd \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03bf Home Assistant.\n\n\u0391\u03c6\u03b1\u03b9\u03c1\u03ad\u03c3\u03c4\u03b5 \u03c4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 YAML \u03b1\u03c0\u03cc \u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf configuration.yaml \u03ba\u03b1\u03b9 \u03b5\u03c0\u03b1\u03bd\u03b5\u03ba\u03ba\u03b9\u03bd\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf Home Assistant \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03bf\u03c1\u03b8\u03ce\u03c3\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u03c0\u03c1\u03cc\u03b2\u03bb\u03b7\u03bc\u03b1.", + "title": "\u0397 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 Moon YAML \u03ad\u03c7\u03b5\u03b9 \u03b1\u03c6\u03b1\u03b9\u03c1\u03b5\u03b8\u03b5\u03af" + } + }, "title": "\u03a6\u03b5\u03b3\u03b3\u03ac\u03c1\u03b9" } \ No newline at end of file diff --git a/homeassistant/components/moon/translations/et.json b/homeassistant/components/moon/translations/et.json index 28eccf2543924748a512dbbef3f47876d3e3e9a1..5f51cc47ab105bed038693bd987bcc8ba3434b95 100644 --- a/homeassistant/components/moon/translations/et.json +++ b/homeassistant/components/moon/translations/et.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Mooni seadistamine YAML-i abil on eemaldatud.\n\nHome Assistant ei kasuta teie olemasolevat YAML-i konfiguratsiooni.\n\nEemaldage FAILIST CONFIGURATION.yaml YAML-konfiguratsioon ja taask\u00e4ivitage selle probleemi lahendamiseks Home Assistant.", + "title": "Moon YAML konfiguratsioon on eemaldatud" + } + }, "title": "Kuu" } \ No newline at end of file diff --git a/homeassistant/components/moon/translations/hu.json b/homeassistant/components/moon/translations/hu.json index 8c6a9f42071b29e9eae186060fcb1324a3eb27e2..ed7722ed4846bd4d93d71015ac16ac7cc2f20ad6 100644 --- a/homeassistant/components/moon/translations/hu.json +++ b/homeassistant/components/moon/translations/hu.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "A Moon YAML haszn\u00e1lat\u00e1val t\u00f6rt\u00e9n\u0151 konfigur\u00e1l\u00e1sa elt\u00e1vol\u00edt\u00e1sra ker\u00fclt.\n\nA megl\u00e9v\u0151 YAML konfigur\u00e1ci\u00f3t a Home Assistant nem haszn\u00e1lja.\n\nA probl\u00e9ma megold\u00e1s\u00e1hoz t\u00e1vol\u00edtsa el a YAML konfigur\u00e1ci\u00f3t a configuration.yaml f\u00e1jlb\u00f3l, \u00e9s ind\u00edtsa \u00fajra a Home Assistantot.", + "title": "A Moon YAML konfigur\u00e1ci\u00f3 elt\u00e1vol\u00edt\u00e1sra ker\u00fclt" + } + }, "title": "Hold" } \ No newline at end of file diff --git a/homeassistant/components/moon/translations/id.json b/homeassistant/components/moon/translations/id.json index 42b208bfb7ebab26be2965548810fcc4b567c96f..01c0ce2df1423f9b3e0edd128f8049d7eeb70f45 100644 --- a/homeassistant/components/moon/translations/id.json +++ b/homeassistant/components/moon/translations/id.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Proses konfigurasi Integrasi Bulan lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Bulan telah dihapus" + } + }, "title": "Bulan" } \ No newline at end of file diff --git a/homeassistant/components/moon/translations/it.json b/homeassistant/components/moon/translations/it.json index af891532233505de8c41abd72f8e35e7dbf47037..f510b6b583735af59bc428de14b36315c61adf73 100644 --- a/homeassistant/components/moon/translations/it.json +++ b/homeassistant/components/moon/translations/it.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "La configurazione di Moon tramite YAML \u00e8 stata rimossa. \n\nLa tua configurazione YAML esistente non viene utilizzata da Home Assistant. \n\nRimuovere la configurazione YAML dal file configuration.yaml e riavviare Home Assistant per risolvere questo problema.", + "title": "La configurazione YAML di Moon \u00e8 stata rimossa" + } + }, "title": "Luna" } \ No newline at end of file diff --git a/homeassistant/components/moon/translations/no.json b/homeassistant/components/moon/translations/no.json index c86dae4f615fae4e303427cb44a0d6ef36362c34..a4010e777879191b533e3d68e541d454fdaab7f5 100644 --- a/homeassistant/components/moon/translations/no.json +++ b/homeassistant/components/moon/translations/no.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Konfigurering av Moon ved hjelp av YAML er fjernet. \n\n Din eksisterende YAML-konfigurasjon brukes ikke av Home Assistant. \n\n Fjern YAML-konfigurasjonen fra configuration.yaml-filen og start Home Assistant p\u00e5 nytt for \u00e5 fikse dette problemet.", + "title": "Moon YAML-konfigurasjonen er fjernet" + } + }, "title": "M\u00e5ne" } \ No newline at end of file diff --git a/homeassistant/components/moon/translations/pl.json b/homeassistant/components/moon/translations/pl.json index fe01b71dadff88405cfb2e7a44c9acdba1ac6870..c3ebaeca4a8ed2b8a8b28b55c2f2954614b0b76b 100644 --- a/homeassistant/components/moon/translations/pl.json +++ b/homeassistant/components/moon/translations/pl.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Konfiguracja Ksi\u0119\u017cyca za pomoc\u0105 YAML zosta\u0142a usuni\u0119ta. \n\nTwoja istniej\u0105ca konfiguracja YAML nie jest u\u017cywana przez Home Assistant. \n\nUsu\u0144 konfiguracj\u0119 YAML z pliku configuration.yaml i uruchom ponownie Home Assistant, aby rozwi\u0105za\u0107 ten problem.", + "title": "Konfiguracja YAML dla Ksi\u0119\u017cyca zosta\u0142a usuni\u0119ta" + } + }, "title": "Ksi\u0119\u017cyc" } \ No newline at end of file diff --git a/homeassistant/components/moon/translations/pt-BR.json b/homeassistant/components/moon/translations/pt-BR.json index 1570f4110ec8f8d124fdc8bcba03c95eed3ddc8e..1e2d6c5c3f032d85d7640169bf00aa1a92924c8a 100644 --- a/homeassistant/components/moon/translations/pt-BR.json +++ b/homeassistant/components/moon/translations/pt-BR.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "A configura\u00e7\u00e3o da Moon usando YAML foi removida. \n\n Sua configura\u00e7\u00e3o YAML existente n\u00e3o \u00e9 usada pelo Home Assistant. \n\n Remova a configura\u00e7\u00e3o YAML do arquivo configuration.yaml e reinicie o Home Assistant para corrigir esse problema.", + "title": "A configura\u00e7\u00e3o YAML da Moon foi removida" + } + }, "title": "Moon" } \ No newline at end of file diff --git a/homeassistant/components/moon/translations/sv.json b/homeassistant/components/moon/translations/sv.json index 38aaa7157e69974486490417da7b55d31b93ace4..8c6b4e209266ec71bc462f39ace36068eb4bb162 100644 --- a/homeassistant/components/moon/translations/sv.json +++ b/homeassistant/components/moon/translations/sv.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Att konfigurera Moon med YAML har tagits bort. \n\n Din befintliga YAML-konfiguration anv\u00e4nds inte av Home Assistant. \n\n Ta bort YAML-konfigurationen fr\u00e5n filen configuration.yaml och starta om Home Assistant f\u00f6r att \u00e5tg\u00e4rda problemet.", + "title": "Moon YAML-konfigurationen har tagits bort" + } + }, "title": "M\u00e5nen" } \ No newline at end of file diff --git a/homeassistant/components/moon/translations/tr.json b/homeassistant/components/moon/translations/tr.json index 0abcd94692cc6663338d0714f7b84f8efe92e0c4..d1c5810d269573d40900a0d47f47381d8ee22af4 100644 --- a/homeassistant/components/moon/translations/tr.json +++ b/homeassistant/components/moon/translations/tr.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Moon'un YAML kullan\u0131larak yap\u0131land\u0131r\u0131lmas\u0131 kald\u0131r\u0131ld\u0131.\n\nMevcut YAML yap\u0131land\u0131rman\u0131z Home Assistant taraf\u0131ndan kullan\u0131lmaz.\n\nYAML yap\u0131land\u0131rmas\u0131n\u0131 configuration.yaml dosyan\u0131zdan kald\u0131r\u0131n ve bu sorunu gidermek i\u00e7in Home Assistant'\u0131 yeniden ba\u015flat\u0131n.", + "title": "Moon YAML yap\u0131land\u0131rmas\u0131 kald\u0131r\u0131ld\u0131" + } + }, "title": "Ay" } \ No newline at end of file diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index e6e4c50c7fe2aaf6f89b5938c7724d45f76a8cf8..6d80d31a69d42d3732cc0ae9dcc444ef14abbd60 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -19,21 +19,5 @@ ], "codeowners": ["@starkillerOG"], "iot_class": "local_push", - "loggers": ["motionblinds"], - "supported_brands": { - "amp_motorization": "AMP Motorization", - "bliss_automation": "Bliss Automation", - "bloc_blinds": "Bloc Blinds", - "brel_home": "Brel Home", - "3_day_blinds": "3 Day Blinds", - "dooya": "Dooya", - "gaviota": "Gaviota", - "hurrican_shutters_wholesale": "Hurrican Shutters Wholesale", - "ismartwindow": "iSmartWindow", - "martec": "Martec", - "raven_rock_mfg": "Raven Rock MFG", - "smart_blinds": "Smart Blinds", - "smart_home": "Smart Home", - "uprise_smart_shades": "Uprise Smart Shades" - } + "loggers": ["motionblinds"] } diff --git a/homeassistant/components/motioneye/media_source.py b/homeassistant/components/motioneye/media_source.py index 85fe3985b930638f1f9d9be4e6d4c6efcb6e0b52..20fc4359ab26d89c01c00e16c7c1b4fc71da7c42 100644 --- a/homeassistant/components/motioneye/media_source.py +++ b/homeassistant/components/motioneye/media_source.py @@ -284,7 +284,13 @@ class MotionEyeMediaSource(MediaSource): sub_dirs: set[str] = set() parts = parsed_path.parts - for media in resp.get(KEY_MEDIA_LIST, []): + media_list = resp.get(KEY_MEDIA_LIST, []) + + def get_media_sort_key(media: dict) -> str: + """Get media sort key.""" + return media.get(KEY_PATH, "") + + for media in sorted(media_list, key=get_media_sort_key): if ( KEY_PATH not in media or KEY_MIME_TYPE not in media diff --git a/homeassistant/components/motioneye/translations/bg.json b/homeassistant/components/motioneye/translations/bg.json index d2db5257b51486409a75d396681018cf669afee8..f5716dcf95149089a1806b2ba56d9c76737acfde 100644 --- a/homeassistant/components/motioneye/translations/bg.json +++ b/homeassistant/components/motioneye/translations/bg.json @@ -1,8 +1,14 @@ { "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", + "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\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", - "invalid_url": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d URL" + "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_url": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d URL", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { diff --git a/homeassistant/components/motioneye/translations/nb.json b/homeassistant/components/motioneye/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/motioneye/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/no.json b/homeassistant/components/motioneye/translations/no.json index 1d7f3bab29fda886b797e414c86497d292083ef9..91b06e86067a2ff213dac99dc113d159c87715b3 100644 --- a/homeassistant/components/motioneye/translations/no.json +++ b/homeassistant/components/motioneye/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Tjenesten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 62aad6ca7fe2db1ec2add68e052078005d2bc70b..06921105aae0aa39599ccdfb23bf494595f4b10f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable +from datetime import datetime import logging from typing import Any, cast @@ -13,10 +14,12 @@ from homeassistant import config as conf_util, config_entries from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_CLIENT_ID, CONF_DISCOVERY, CONF_PASSWORD, CONF_PAYLOAD, CONF_PORT, + CONF_PROTOCOL, CONF_USERNAME, SERVICE_RELOAD, ) @@ -31,6 +34,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.reload import ( async_integration_yaml_config, async_reload_integration_platforms, @@ -49,7 +53,9 @@ from .client import ( # noqa: F401 ) from .config_integration import ( CONFIG_SCHEMA_BASE, + CONFIG_SCHEMA_ENTRY, DEFAULT_VALUES, + DEPRECATED_CERTIFICATE_CONFIG_KEYS, DEPRECATED_CONFIG_KEYS, ) from .const import ( # noqa: F401 @@ -59,10 +65,15 @@ from .const import ( # noqa: F401 ATTR_TOPIC, CONF_BIRTH_MESSAGE, CONF_BROKER, + CONF_CERTIFICATE, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, CONF_COMMAND_TOPIC, CONF_DISCOVERY_PREFIX, + CONF_KEEPALIVE, CONF_QOS, CONF_STATE_TOPIC, + CONF_TLS_INSECURE, CONF_TLS_VERSION, CONF_TOPIC, CONF_WILL_MESSAGE, @@ -85,7 +96,9 @@ from .models import ( # noqa: F401 ) from .util import ( _VALID_QOS_SCHEMA, + async_create_certificate_temp_files, get_mqtt_data, + migrate_certificate_file_to_content, mqtt_config_entry_enabled, valid_publish_topic, valid_subscribe_topic, @@ -96,7 +109,7 @@ _LOGGER = logging.getLogger(__name__) SERVICE_PUBLISH = "publish" SERVICE_DUMP = "dump" -MANDATORY_DEFAULT_VALUES = (CONF_PORT,) +MANDATORY_DEFAULT_VALUES = (CONF_PORT, CONF_DISCOVERY_PREFIX) ATTR_TOPIC_TEMPLATE = "topic_template" ATTR_PAYLOAD_TEMPLATE = "payload_template" @@ -110,9 +123,17 @@ CONNECTION_FAILED_RECOVERABLE = "connection_failed_recoverable" CONFIG_ENTRY_CONFIG_KEYS = [ CONF_BIRTH_MESSAGE, CONF_BROKER, + CONF_CERTIFICATE, + CONF_CLIENT_ID, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, CONF_DISCOVERY, + CONF_DISCOVERY_PREFIX, + CONF_KEEPALIVE, CONF_PASSWORD, CONF_PORT, + CONF_PROTOCOL, + CONF_TLS_INSECURE, CONF_USERNAME, CONF_WILL_MESSAGE, ] @@ -122,9 +143,17 @@ CONFIG_SCHEMA = vol.Schema( DOMAIN: vol.All( cv.deprecated(CONF_BIRTH_MESSAGE), # Deprecated in HA Core 2022.3 cv.deprecated(CONF_BROKER), # Deprecated in HA Core 2022.3 + cv.deprecated(CONF_CERTIFICATE), # Deprecated in HA Core 2022.11 + cv.deprecated(CONF_CLIENT_ID), # Deprecated in HA Core 2022.11 + cv.deprecated(CONF_CLIENT_CERT), # Deprecated in HA Core 2022.11 + cv.deprecated(CONF_CLIENT_KEY), # Deprecated in HA Core 2022.11 cv.deprecated(CONF_DISCOVERY), # Deprecated in HA Core 2022.3 + cv.deprecated(CONF_DISCOVERY_PREFIX), # Deprecated in HA Core 2022.11 + cv.deprecated(CONF_KEEPALIVE), # Deprecated in HA Core 2022.11 cv.deprecated(CONF_PASSWORD), # Deprecated in HA Core 2022.3 cv.deprecated(CONF_PORT), # Deprecated in HA Core 2022.3 + cv.deprecated(CONF_PROTOCOL), # Deprecated in HA Core 2022.11 + cv.deprecated(CONF_TLS_INSECURE), # Deprecated in HA Core 2022.11 cv.deprecated(CONF_TLS_VERSION), # Deprecated June 2020 cv.deprecated(CONF_USERNAME), # Deprecated in HA Core 2022.3 cv.deprecated(CONF_WILL_MESSAGE), # Deprecated in HA Core 2022.3 @@ -153,7 +182,7 @@ MQTT_PUBLISH_SCHEMA = vol.All( async def _async_setup_discovery( - hass: HomeAssistant, conf: ConfigType, config_entry + hass: HomeAssistant, conf: ConfigType, config_entry: ConfigEntry ) -> None: """Try to start the discovery of MQTT devices. @@ -206,22 +235,31 @@ def _filter_entry_config(hass: HomeAssistant, entry: ConfigEntry) -> None: if entry.data.keys() != filtered_data.keys(): _LOGGER.warning( "The following unsupported configuration options were removed from the " - "MQTT config entry: %s. Add them to configuration.yaml if they are needed", + "MQTT config entry: %s", entry.data.keys() - filtered_data.keys(), ) hass.config_entries.async_update_entry(entry, data=filtered_data) -def _merge_basic_config( +async def _async_merge_basic_config( hass: HomeAssistant, entry: ConfigEntry, yaml_config: dict[str, Any] ) -> None: """Merge basic options in configuration.yaml config with config entry. This mends incomplete migration from old version of HA Core. """ - entry_updated = False entry_config = {**entry.data} + for key in DEPRECATED_CERTIFICATE_CONFIG_KEYS: + if key in yaml_config and key not in entry_config: + if ( + content := await hass.async_add_executor_job( + migrate_certificate_file_to_content, yaml_config[key] + ) + ) is not None: + entry_config[key] = content + entry_updated = True + for key in DEPRECATED_CONFIG_KEYS: if key in yaml_config and key not in entry_config: entry_config[key] = yaml_config[key] @@ -236,7 +274,7 @@ def _merge_basic_config( hass.config_entries.async_update_entry(entry, data=entry_config) -def _merge_extended_config(entry, conf): +def _merge_extended_config(entry: ConfigEntry, conf: ConfigType) -> dict[str, Any]: """Merge advanced options in configuration.yaml config with config entry.""" # Add default values conf = {**DEFAULT_VALUES, **conf} @@ -251,7 +289,9 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - await hass.config_entries.async_reload(entry.entry_id) -async def async_fetch_config(hass: HomeAssistant, entry: ConfigEntry) -> dict | None: +async def async_fetch_config( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any] | None: """Fetch fresh MQTT yaml config from the hass config when (re)loading the entry.""" mqtt_data = get_mqtt_data(hass) if mqtt_data.reload_entry: @@ -262,7 +302,7 @@ async def async_fetch_config(hass: HomeAssistant, entry: ConfigEntry) -> dict | _filter_entry_config(hass, entry) # Merge basic configuration, and add missing defaults for basic options - _merge_basic_config(hass, entry, mqtt_data.config or {}) + await _async_merge_basic_config(hass, entry, mqtt_data.config or {}) # Bail out if broker setting is missing if CONF_BROKER not in entry.data: _LOGGER.error("MQTT broker is not configured, please configure it") @@ -271,7 +311,7 @@ async def async_fetch_config(hass: HomeAssistant, entry: ConfigEntry) -> dict | # If user doesn't have configuration.yaml config, generate default values # for options not in config entry data if (conf := mqtt_data.config) is None: - conf = CONFIG_SCHEMA_BASE(dict(entry.data)) + conf = CONFIG_SCHEMA_ENTRY(dict(entry.data)) # User has configuration.yaml config, warn about config entry overrides elif any(key in conf for key in entry.data): @@ -279,12 +319,28 @@ async def async_fetch_config(hass: HomeAssistant, entry: ConfigEntry) -> dict | override = {k: entry.data[k] for k in shared_keys if conf[k] != entry.data[k]} if CONF_PASSWORD in override: override[CONF_PASSWORD] = "********" + if CONF_CLIENT_KEY in override: + override[CONF_CLIENT_KEY] = "-----PRIVATE KEY-----" if override: _LOGGER.warning( "Deprecated configuration settings found in configuration.yaml. " "These settings from your configuration entry will override: %s", override, ) + # Register a repair issue + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_broker_settings", + breaks_in_ha_version="2023.4.0", # Warning first added in 2022.11.0 + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_broker_settings", + translation_placeholders={ + "more_info_url": "https://www.home-assistant.io/integrations/mqtt/", + "deprecated_settings": str(shared_keys)[1:-1], + }, + ) # Merge advanced configuration values from configuration.yaml conf = _merge_extended_config(entry, conf) @@ -299,6 +355,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if (conf := await async_fetch_config(hass, entry)) is None: # Bail out return False + await async_create_certificate_temp_files(hass, dict(entry.data)) mqtt_data.client = MQTT(hass, entry, conf) # Restore saved subscriptions if mqtt_data.subscriptions_to_restore: @@ -320,7 +377,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: retain: bool = call.data[ATTR_RETAIN] if msg_topic_template is not None: try: - rendered_topic = template.Template( + rendered_topic: Any = template.Template( msg_topic_template, hass ).async_render(parse_result=False) msg_topic = valid_publish_topic(rendered_topic) @@ -366,20 +423,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_dump_service(call: ServiceCall) -> None: """Handle MQTT dump service calls.""" - messages = [] + messages: list[tuple[str, str]] = [] @callback - def collect_msg(msg): - messages.append((msg.topic, msg.payload.replace("\n", ""))) + def collect_msg(msg: ReceiveMessage) -> None: + messages.append((msg.topic, str(msg.payload).replace("\n", ""))) unsub = await async_subscribe(hass, call.data["topic"], collect_msg) - def write_dump(): - with open(hass.config.path("mqtt_dump.txt"), "wt", encoding="utf8") as fp: + def write_dump() -> None: + with open(hass.config.path("mqtt_dump.txt"), "w", encoding="utf8") as fp: for msg in messages: fp.write(",".join(msg) + "\n") - async def finish_dump(_): + async def finish_dump(_: datetime) -> None: """Write dump to file.""" unsub() await hass.async_add_executor_job(write_dump) @@ -439,7 +496,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) - async def async_forward_entry_setup_and_setup_discovery(config_entry): + async def async_forward_entry_setup_and_setup_discovery( + config_entry: ConfigEntry, + conf: ConfigType, + ) -> None: """Forward the config entry setup to the platforms and set up discovery.""" reload_manual_setup: bool = False # Local import to avoid circular dependencies @@ -477,7 +537,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if reload_manual_setup: await async_reload_manual_mqtt_items(hass) - await async_forward_entry_setup_and_setup_discovery(entry) + await async_forward_entry_setup_and_setup_discovery(entry, conf) return True @@ -496,7 +556,9 @@ async def async_reload_manual_mqtt_items(hass: HomeAssistant) -> None: {vol.Required("type"): "mqtt/device/debug_info", vol.Required("device_id"): str} ) @callback -def websocket_mqtt_info(hass, connection, msg): +def websocket_mqtt_info( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: """Get MQTT debug info for device.""" device_id = msg["device_id"] mqtt_info = debug_info.info_for_device(hass, device_id) @@ -511,12 +573,14 @@ def websocket_mqtt_info(hass, connection, msg): } ) @websocket_api.async_response -async def websocket_subscribe(hass, connection, msg): +async def websocket_subscribe( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: """Subscribe to a MQTT topic.""" if not connection.user.is_admin: raise Unauthorized - async def forward_messages(mqttmsg: ReceiveMessage): + async def forward_messages(mqttmsg: ReceiveMessage) -> None: """Forward events to websocket.""" try: payload = cast(bytes, mqttmsg.payload).decode( @@ -556,12 +620,12 @@ def async_subscribe_connection_status( """Subscribe to MQTT connection changes.""" connection_status_callback_job = HassJob(connection_status_callback) - async def connected(): + async def connected() -> None: task = hass.async_run_hass_job(connection_status_callback_job, True) if task: await task - async def disconnected(): + async def disconnected() -> None: task = hass.async_run_hass_job(connection_status_callback_job, False) if task: await task @@ -572,7 +636,7 @@ def async_subscribe_connection_status( } @callback - def unsubscribe(): + def unsubscribe() -> None: subscriptions["connect"]() subscriptions["disconnect"]() diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 758e978bb46d7798fa7133f7b0e851796b6771de..00f6d3575536f4a1321e0fd4263b59c23284df45 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -52,6 +52,7 @@ ABBREVIATIONS = { "e": "encoding", "en": "enabled_by_default", "ent_cat": "entity_category", + "ent_pic": "entity_picture", "err_t": "error_topic", "err_tpl": "error_template", "fanspd_t": "fan_speed_topic", @@ -169,6 +170,8 @@ ABBREVIATIONS = { "pr_mode_val_tpl": "preset_mode_value_template", "pr_modes": "preset_modes", "r_tpl": "red_template", + "rel_s": "release_summary", + "rel_u": "release_url", "ret": "retain", "rgb_cmd_tpl": "rgb_command_template", "rgb_cmd_t": "rgb_command_topic", @@ -242,6 +245,7 @@ ABBREVIATIONS = { "tilt_opt": "tilt_optimistic", "tilt_status_t": "tilt_status_topic", "tilt_status_tpl": "tilt_status_template", + "tit": "title", "t": "topic", "uniq_id": "unique_id", "unit_of_meas": "unit_of_measurement", @@ -255,6 +259,9 @@ ABBREVIATIONS = { "xy_cmd_t": "xy_command_topic", "xy_stat_t": "xy_state_topic", "xy_val_tpl": "xy_value_template", + "l_ver_t": "latest_version_topic", + "l_ver_tpl": "latest_version_template", + "pl_inst": "payload_install", } DEVICE_ABBREVIATIONS = { diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 79d720338be02da38b9b0df609ca83da307dc312..e909a3785814653fe6a172c9ade05af70da6adc2 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -54,6 +54,7 @@ from .const import ( CONF_TLS_INSECURE, CONF_WILL_MESSAGE, DEFAULT_ENCODING, + DEFAULT_PROTOCOL, DEFAULT_QOS, MQTT_CONNECTED, MQTT_DISCONNECTED, @@ -67,7 +68,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, mqtt_config_entry_enabled +from .util import get_file_path, get_mqtt_data, mqtt_config_entry_enabled if TYPE_CHECKING: # Only import for paho-mqtt type checking here, imports are done locally @@ -244,7 +245,7 @@ def subscribe( async_subscribe(hass, topic, msg_callback, qos, encoding), hass.loop ).result() - def remove(): + def remove() -> None: """Remove listener convert.""" run_callback_threadsafe(hass.loop, async_remove).result() @@ -272,7 +273,7 @@ class MqttClientSetup: # should be able to optionally rely on MQTT. import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel - if config[CONF_PROTOCOL] == PROTOCOL_31: + if config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL) == PROTOCOL_31: proto = mqtt.MQTTv31 else: proto = mqtt.MQTTv311 @@ -291,11 +292,13 @@ class MqttClientSetup: if username is not None: self._client.username_pw_set(username, password) - if (certificate := config.get(CONF_CERTIFICATE)) == "auto": + if ( + certificate := get_file_path(CONF_CERTIFICATE, config.get(CONF_CERTIFICATE)) + ) == "auto": certificate = certifi.where() - client_key = config.get(CONF_CLIENT_KEY) - client_cert = config.get(CONF_CLIENT_CERT) + client_key = get_file_path(CONF_CLIENT_KEY, config.get(CONF_CLIENT_KEY)) + client_cert = get_file_path(CONF_CLIENT_CERT, config.get(CONF_CLIENT_CERT)) tls_insecure = config.get(CONF_TLS_INSECURE) if certificate is not None: self._client.tls_set( @@ -338,7 +341,7 @@ class MQTT: self._ha_started = asyncio.Event() self._last_subscribe = time.time() self._mqttc: mqtt.Client = None - self._cleanup_on_unload: list[Callable] = [] + self._cleanup_on_unload: list[Callable[[], None]] = [] self._paho_lock = asyncio.Lock() # Prevents parallel calls to the MQTT client self._pending_operations: dict[int, asyncio.Event] = {} @@ -349,14 +352,14 @@ class MQTT: else: @callback - def ha_started(_): + def ha_started(_: Event) -> None: self._ha_started.set() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, ha_started) self.init_client() - async def async_stop_mqtt(_event: Event): + async def async_stop_mqtt(_event: Event) -> None: """Stop MQTT component.""" await self.async_disconnect() @@ -503,9 +506,11 @@ class MQTT: def _client_unsubscribe(topic: str) -> int: result: int | None = None + mid: int | None = None result, mid = self._mqttc.unsubscribe(topic) _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid) _raise_on_error(result) + assert mid return mid if any(other.topic == topic for other in self.subscriptions): @@ -550,7 +555,13 @@ class MQTT: if errors: _raise_on_errors(errors) - def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code: int) -> None: + def _mqtt_on_connect( + self, + _mqttc: mqtt.Client, + _userdata: None, + _flags: dict[str, Any], + result_code: int, + ) -> None: """On connect callback. Resubscribe to all topics we were subscribed to and publish birth @@ -593,7 +604,7 @@ class MQTT: and ATTR_TOPIC in self.conf[CONF_BIRTH_MESSAGE] ): - async def publish_birth_message(birth_message): + async def publish_birth_message(birth_message: PublishMessage) -> None: await self._ha_started.wait() # Wait for Home Assistant to start await self._discovery_cooldown() # Wait for MQTT discovery to cool down await self.async_publish( @@ -608,7 +619,9 @@ class MQTT: publish_birth_message(birth_message), self.hass.loop ) - def _mqtt_on_message(self, _mqttc, _userdata, msg) -> None: + def _mqtt_on_message( + self, _mqttc: mqtt.Client, _userdata: None, msg: MQTTMessage + ) -> None: """Message received callback.""" self.hass.add_job(self._mqtt_handle_message, msg) @@ -660,7 +673,13 @@ class MQTT: ) self._mqtt_data.state_write_requests.process_write_state_requests() - def _mqtt_on_callback(self, _mqttc, _userdata, mid, _granted_qos=None) -> None: + def _mqtt_on_callback( + self, + _mqttc: mqtt.Client, + _userdata: None, + mid: int, + _granted_qos: tuple[Any, ...] | None = None, + ) -> None: """Publish / Subscribe / Unsubscribe callback.""" self.hass.add_job(self._mqtt_handle_mid, mid) @@ -676,7 +695,9 @@ class MQTT: if mid not in self._pending_operations: self._pending_operations[mid] = asyncio.Event() - def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None: + def _mqtt_on_disconnect( + self, _mqttc: mqtt.Client, _userdata: None, result_code: int + ) -> None: """Disconnected callback.""" self.connected = False dispatcher_send(self.hass, MQTT_DISCONNECTED) @@ -704,7 +725,7 @@ class MQTT: del self._pending_operations[mid] self._pending_operations_condition.notify_all() - async def _discovery_cooldown(self): + async def _discovery_cooldown(self) -> None: now = time.time() # Reset discovery and subscribe cooldowns self._mqtt_data.last_discovery = now diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 5d21619c4988fd5fa474aa061c959880b5c2b878..ec8183487012fac17bca500011ae608fb83ba9f7 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -2,14 +2,21 @@ from __future__ import annotations from collections import OrderedDict +from collections.abc import Callable import queue +from ssl import PROTOCOL_TLS, SSLContext, SSLError +from types import MappingProxyType from typing import Any +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.x509 import load_pem_x509_certificate import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import HassioServiceInfo from homeassistant.const import ( + CONF_CLIENT_ID, CONF_DISCOVERY, CONF_HOST, CONF_PASSWORD, @@ -18,11 +25,26 @@ from homeassistant.const import ( CONF_PROTOCOL, CONF_USERNAME, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.selector import ( + BooleanSelector, + FileSelector, + FileSelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from .client import MqttClientSetup +from .config_integration import CONFIG_SCHEMA_ENTRY from .const import ( ATTR_PAYLOAD, ATTR_QOS, @@ -30,16 +52,84 @@ from .const import ( ATTR_TOPIC, CONF_BIRTH_MESSAGE, CONF_BROKER, + CONF_CERTIFICATE, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, + CONF_DISCOVERY_PREFIX, + CONF_KEEPALIVE, + CONF_TLS_INSECURE, CONF_WILL_MESSAGE, DEFAULT_BIRTH, DEFAULT_DISCOVERY, + DEFAULT_ENCODING, + DEFAULT_KEEPALIVE, + DEFAULT_PORT, + DEFAULT_PREFIX, + DEFAULT_PROTOCOL, DEFAULT_WILL, DOMAIN, + SUPPORTED_PROTOCOLS, +) +from .util import ( + MQTT_WILL_BIRTH_SCHEMA, + async_create_certificate_temp_files, + get_file_path, + valid_publish_topic, ) -from .util import MQTT_WILL_BIRTH_SCHEMA, get_mqtt_data MQTT_TIMEOUT = 5 +ADVANCED_OPTIONS = "advanced_options" +SET_CA_CERT = "set_ca_cert" +SET_CLIENT_CERT = "set_client_cert" + +BOOLEAN_SELECTOR = BooleanSelector() +TEXT_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) +PUBLISH_TOPIC_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) +PORT_SELECTOR = vol.All( + NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535)), + vol.Coerce(int), +) +PASSWORD_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)) +QOS_SELECTOR = vol.All( + NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2)), + vol.Coerce(int), +) +KEEPALIVE_SELECTOR = vol.All( + NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, min=15, step="any", unit_of_measurement="sec" + ) + ), + vol.Coerce(int), +) +PROTOCOL_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=SUPPORTED_PROTOCOLS, + mode=SelectSelectorMode.DROPDOWN, + ) +) +CA_VERIFICATION_MODES = [ + SelectOptionDict(value="off", label="Off"), + SelectOptionDict(value="auto", label="Auto"), + SelectOptionDict(value="custom", label="Custom"), +] +BROKER_VERIFICATION_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=CA_VERIFICATION_MODES, + mode=SelectSelectorMode.DROPDOWN, + ) +) + +# mime configuration from https://pki-tutorial.readthedocs.io/en/latest/mime.html +CA_CERT_UPLOAD_SELECTOR = FileSelector( + FileSelectorConfig(accept=".crt,application/x-x509-ca-cert") +) +CERT_UPLOAD_SELECTOR = FileSelector( + FileSelectorConfig(accept=".crt,application/x-x509-user-cert") +) +KEY_UPLOAD_SELECTOR = FileSelector(FileSelectorConfig(accept=".key,application/pkcs8")) + class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -69,32 +159,31 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm the setup.""" - errors = {} - - if user_input is not None: + errors: dict[str, str] = {} + fields: OrderedDict[Any, Any] = OrderedDict() + validated_user_input: dict[str, Any] = {} + if await async_get_broker_settings( + self.hass, + fields, + None, + user_input, + validated_user_input, + errors, + ): can_connect = await self.hass.async_add_executor_job( try_connection, - get_mqtt_data(self.hass, True).config or {}, - user_input[CONF_BROKER], - user_input[CONF_PORT], - user_input.get(CONF_USERNAME), - user_input.get(CONF_PASSWORD), + validated_user_input, ) if can_connect: - user_input[CONF_DISCOVERY] = DEFAULT_DISCOVERY + validated_user_input[CONF_DISCOVERY] = DEFAULT_DISCOVERY return self.async_create_entry( - title=user_input[CONF_BROKER], data=user_input + title=validated_user_input[CONF_BROKER], + data=validated_user_input, ) errors["base"] = "cannot_connect" - fields = OrderedDict() - fields[vol.Required(CONF_BROKER)] = str - fields[vol.Required(CONF_PORT, default=1883)] = vol.Coerce(int) - fields[vol.Optional(CONF_USERNAME)] = str - fields[vol.Optional(CONF_PASSWORD)] = str - return self.async_show_form( step_id="broker", data_schema=vol.Schema(fields), errors=errors ) @@ -111,26 +200,22 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm a Hass.io discovery.""" - errors = {} + errors: dict[str, str] = {} assert self._hassio_discovery if user_input is not None: - data = self._hassio_discovery + data: dict[str, Any] = self._hassio_discovery.copy() + data[CONF_BROKER] = data.pop(CONF_HOST) can_connect = await self.hass.async_add_executor_job( try_connection, - get_mqtt_data(self.hass, True).config or {}, - data[CONF_HOST], - data[CONF_PORT], - data.get(CONF_USERNAME), - data.get(CONF_PASSWORD), - data.get(CONF_PROTOCOL), + data, ) if can_connect: return self.async_create_entry( title=data["addon"], data={ - CONF_BROKER: data[CONF_HOST], + CONF_BROKER: data[CONF_BROKER], CONF_PORT: data[CONF_PORT], CONF_USERNAME: data.get(CONF_USERNAME), CONF_PASSWORD: data.get(CONF_PASSWORD), @@ -154,7 +239,7 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): """Initialize MQTT options flow.""" self.config_entry = config_entry self.broker_config: dict[str, str | int] = {} - self.options = dict(config_entry.options) + self.options = config_entry.options async def async_step_init(self, user_input: None = None) -> FlowResult: """Manage the MQTT options.""" @@ -164,46 +249,28 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the MQTT broker configuration.""" - mqtt_data = get_mqtt_data(self.hass, True) - yaml_config = mqtt_data.config or {} - errors = {} - current_config = self.config_entry.data - if user_input is not None: + errors: dict[str, str] = {} + fields: OrderedDict[Any, Any] = OrderedDict() + validated_user_input: dict[str, Any] = {} + if await async_get_broker_settings( + self.hass, + fields, + self.config_entry.data, + user_input, + validated_user_input, + errors, + ): can_connect = await self.hass.async_add_executor_job( try_connection, - yaml_config, - user_input[CONF_BROKER], - user_input[CONF_PORT], - user_input.get(CONF_USERNAME), - user_input.get(CONF_PASSWORD), + validated_user_input, ) if can_connect: - self.broker_config.update(user_input) + self.broker_config.update(validated_user_input) return await self.async_step_options() errors["base"] = "cannot_connect" - fields = OrderedDict() - current_broker = current_config.get(CONF_BROKER, yaml_config.get(CONF_BROKER)) - current_port = current_config.get(CONF_PORT, yaml_config.get(CONF_PORT)) - current_user = current_config.get(CONF_USERNAME, yaml_config.get(CONF_USERNAME)) - current_pass = current_config.get(CONF_PASSWORD, yaml_config.get(CONF_PASSWORD)) - fields[vol.Required(CONF_BROKER, default=current_broker)] = str - fields[vol.Required(CONF_PORT, default=current_port)] = vol.Coerce(int) - fields[ - vol.Optional( - CONF_USERNAME, - description={"suggested_value": current_user}, - ) - ] = str - fields[ - vol.Optional( - CONF_PASSWORD, - description={"suggested_value": current_pass}, - ) - ] = str - return self.async_show_form( step_id="broker", data_schema=vol.Schema(fields), @@ -215,53 +282,70 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the MQTT options.""" - mqtt_data = get_mqtt_data(self.hass, True) errors = {} current_config = self.config_entry.data - yaml_config = mqtt_data.config or {} options_config: dict[str, Any] = {} - if user_input is not None: - bad_birth = False - bad_will = False + bad_input: bool = False + + def _birth_will(birt_or_will: str) -> dict[str, Any]: + """Return the user input for birth or will.""" + assert user_input + return { + ATTR_TOPIC: user_input[f"{birt_or_will}_topic"], + ATTR_PAYLOAD: user_input.get(f"{birt_or_will}_payload", ""), + ATTR_QOS: user_input[f"{birt_or_will}_qos"], + ATTR_RETAIN: user_input[f"{birt_or_will}_retain"], + } + + def _validate( + field: str, + values: dict[str, Any], + error_code: str, + schema: Callable[[Any], Any], + ) -> None: + """Validate the user input.""" + nonlocal bad_input + try: + option_values = schema(values) + options_config[field] = option_values + except vol.Invalid: + errors["base"] = error_code + bad_input = True + if user_input is not None: + # validate input + options_config[CONF_DISCOVERY] = user_input[CONF_DISCOVERY] + _validate( + CONF_DISCOVERY_PREFIX, + user_input[CONF_DISCOVERY_PREFIX], + "bad_discovery_prefix", + valid_publish_topic, + ) if "birth_topic" in user_input: - birth_message = { - ATTR_TOPIC: user_input["birth_topic"], - ATTR_PAYLOAD: user_input.get("birth_payload", ""), - ATTR_QOS: user_input["birth_qos"], - ATTR_RETAIN: user_input["birth_retain"], - } - try: - birth_message = MQTT_WILL_BIRTH_SCHEMA(birth_message) - options_config[CONF_BIRTH_MESSAGE] = birth_message - except vol.Invalid: - errors["base"] = "bad_birth" - bad_birth = True + _validate( + CONF_BIRTH_MESSAGE, + _birth_will("birth"), + "bad_birth", + MQTT_WILL_BIRTH_SCHEMA, + ) if not user_input["birth_enable"]: options_config[CONF_BIRTH_MESSAGE] = {} if "will_topic" in user_input: - will_message = { - ATTR_TOPIC: user_input["will_topic"], - ATTR_PAYLOAD: user_input.get("will_payload", ""), - ATTR_QOS: user_input["will_qos"], - ATTR_RETAIN: user_input["will_retain"], - } - try: - will_message = MQTT_WILL_BIRTH_SCHEMA(will_message) - options_config[CONF_WILL_MESSAGE] = will_message - except vol.Invalid: - errors["base"] = "bad_will" - bad_will = True + _validate( + CONF_WILL_MESSAGE, + _birth_will("will"), + "bad_will", + MQTT_WILL_BIRTH_SCHEMA, + ) if not user_input["will_enable"]: options_config[CONF_WILL_MESSAGE] = {} - options_config[CONF_DISCOVERY] = user_input[CONF_DISCOVERY] - - if not bad_birth and not bad_will: + if not bad_input: updated_config = {} updated_config.update(self.broker_config) updated_config.update(options_config) + CONFIG_SCHEMA_ENTRY(updated_config) self.hass.config_entries.async_update_entry( self.config_entry, data=updated_config, @@ -271,22 +355,21 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): birth = { **DEFAULT_BIRTH, - **current_config.get( - CONF_BIRTH_MESSAGE, yaml_config.get(CONF_BIRTH_MESSAGE, {}) - ), + **current_config.get(CONF_BIRTH_MESSAGE, {}), } will = { **DEFAULT_WILL, - **current_config.get( - CONF_WILL_MESSAGE, yaml_config.get(CONF_WILL_MESSAGE, {}) - ), + **current_config.get(CONF_WILL_MESSAGE, {}), } - discovery = current_config.get( - CONF_DISCOVERY, yaml_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY) - ) + discovery = current_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY) + discovery_prefix = current_config.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX) + # build form fields: OrderedDict[vol.Marker, Any] = OrderedDict() - fields[vol.Optional(CONF_DISCOVERY, default=discovery)] = bool + fields[vol.Optional(CONF_DISCOVERY, default=discovery)] = BOOLEAN_SELECTOR + fields[ + vol.Optional(CONF_DISCOVERY_PREFIX, default=discovery_prefix) + ] = PUBLISH_TOPIC_SELECTOR # Birth message is disabled if CONF_BIRTH_MESSAGE = {} fields[ @@ -295,19 +378,21 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): default=CONF_BIRTH_MESSAGE not in current_config or current_config[CONF_BIRTH_MESSAGE] != {}, ) - ] = bool + ] = BOOLEAN_SELECTOR fields[ vol.Optional( "birth_topic", description={"suggested_value": birth[ATTR_TOPIC]} ) - ] = str + ] = PUBLISH_TOPIC_SELECTOR fields[ vol.Optional( "birth_payload", description={"suggested_value": birth[CONF_PAYLOAD]} ) - ] = str - fields[vol.Optional("birth_qos", default=birth[ATTR_QOS])] = vol.In([0, 1, 2]) - fields[vol.Optional("birth_retain", default=birth[ATTR_RETAIN])] = bool + ] = TEXT_SELECTOR + fields[vol.Optional("birth_qos", default=birth[ATTR_QOS])] = QOS_SELECTOR + fields[ + vol.Optional("birth_retain", default=birth[ATTR_RETAIN]) + ] = BOOLEAN_SELECTOR # Will message is disabled if CONF_WILL_MESSAGE = {} fields[ @@ -316,19 +401,21 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): default=CONF_WILL_MESSAGE not in current_config or current_config[CONF_WILL_MESSAGE] != {}, ) - ] = bool + ] = BOOLEAN_SELECTOR fields[ vol.Optional( "will_topic", description={"suggested_value": will[ATTR_TOPIC]} ) - ] = str + ] = PUBLISH_TOPIC_SELECTOR fields[ vol.Optional( "will_payload", description={"suggested_value": will[CONF_PAYLOAD]} ) - ] = str - fields[vol.Optional("will_qos", default=will[ATTR_QOS])] = vol.In([0, 1, 2]) - fields[vol.Optional("will_retain", default=will[ATTR_RETAIN])] = bool + ] = TEXT_SELECTOR + fields[vol.Optional("will_qos", default=will[ATTR_QOS])] = QOS_SELECTOR + fields[ + vol.Optional("will_retain", default=will[ATTR_RETAIN]) + ] = BOOLEAN_SELECTOR return self.async_show_form( step_id="options", @@ -338,38 +425,272 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow): ) +async def async_get_broker_settings( + hass: HomeAssistant, + fields: OrderedDict[Any, Any], + entry_config: MappingProxyType[str, Any] | None, + user_input: dict[str, Any] | None, + validated_user_input: dict[str, Any], + errors: dict[str, str], +) -> bool: + """Build the config flow schema to collect the broker settings. + + Shows advanced options if one or more are configured + or when the advanced_broker_options checkbox was selected. + Returns True when settings are collected successfully. + """ + advanced_broker_options: bool = False + user_input_basic: dict[str, Any] = {} + current_config: dict[str, Any] = ( + entry_config.copy() if entry_config is not None else {} + ) + + async def _async_validate_broker_settings( + config: dict[str, Any], + user_input: dict[str, Any], + validated_user_input: dict[str, Any], + errors: dict[str, str], + ) -> bool: + """Additional validation on broker settings for better error messages.""" + + # Get current certificate settings from config entry + certificate: str | None = ( + "auto" + if user_input.get(SET_CA_CERT, "off") == "auto" + else config.get(CONF_CERTIFICATE) + if user_input.get(SET_CA_CERT, "off") == "custom" + else None + ) + client_certificate: str | None = ( + config.get(CONF_CLIENT_CERT) if user_input.get(SET_CLIENT_CERT) else None + ) + client_key: str | None = ( + config.get(CONF_CLIENT_KEY) if user_input.get(SET_CLIENT_CERT) else None + ) + + # Prepare entry update with uploaded files + validated_user_input.update(user_input) + client_certificate_id: str | None = user_input.get(CONF_CLIENT_CERT) + client_key_id: str | None = user_input.get(CONF_CLIENT_KEY) + if ( + client_certificate_id + and not client_key_id + or not client_certificate_id + and client_key_id + ): + errors["base"] = "invalid_inclusion" + return False + certificate_id: str | None = user_input.get(CONF_CERTIFICATE) + if certificate_id: + with process_uploaded_file(hass, certificate_id) as certiticate_file: + certificate = certiticate_file.read_text(encoding=DEFAULT_ENCODING) + + # Return to form for file upload CA cert or client cert and key + if ( + not client_certificate + and user_input.get(SET_CLIENT_CERT) + and not client_certificate_id + or not certificate + and user_input.get(SET_CA_CERT, "off") == "custom" + and not certificate_id + ): + return False + + if client_certificate_id: + with process_uploaded_file( + hass, client_certificate_id + ) as client_certiticate_file: + client_certificate = client_certiticate_file.read_text( + encoding=DEFAULT_ENCODING + ) + if client_key_id: + with process_uploaded_file(hass, client_key_id) as key_file: + client_key = key_file.read_text(encoding=DEFAULT_ENCODING) + + certificate_data: dict[str, Any] = {} + if certificate: + certificate_data[CONF_CERTIFICATE] = certificate + if client_certificate: + certificate_data[CONF_CLIENT_CERT] = client_certificate + certificate_data[CONF_CLIENT_KEY] = client_key + + validated_user_input.update(certificate_data) + await async_create_certificate_temp_files(hass, certificate_data) + if error := await hass.async_add_executor_job( + check_certicate_chain, + ): + errors["base"] = error + return False + + if SET_CA_CERT in validated_user_input: + del validated_user_input[SET_CA_CERT] + if SET_CLIENT_CERT in validated_user_input: + del validated_user_input[SET_CLIENT_CERT] + return True + + if user_input: + user_input_basic = user_input.copy() + advanced_broker_options = user_input_basic.get(ADVANCED_OPTIONS, False) + if ADVANCED_OPTIONS not in user_input or advanced_broker_options is False: + if await _async_validate_broker_settings( + current_config, + user_input_basic, + validated_user_input, + errors, + ): + return True + # Get defaults settings from previous post + current_broker = user_input_basic.get(CONF_BROKER) + current_port = user_input_basic.get(CONF_PORT, DEFAULT_PORT) + current_user = user_input_basic.get(CONF_USERNAME) + current_pass = user_input_basic.get(CONF_PASSWORD) + else: + # Get default settings from entry or yaml (if any) + current_broker = current_config.get(CONF_BROKER) + current_port = current_config.get(CONF_PORT, DEFAULT_PORT) + current_user = current_config.get(CONF_USERNAME) + current_pass = current_config.get(CONF_PASSWORD) + + # Treat the previous post as an update of the current settings (if there was a basic broker setup step) + current_config.update(user_input_basic) + + # Get default settings for advanced broker options + current_client_id = current_config.get(CONF_CLIENT_ID) + current_keepalive = current_config.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE) + current_ca_certificate = current_config.get(CONF_CERTIFICATE) + current_client_certificate = current_config.get(CONF_CLIENT_CERT) + current_client_key = current_config.get(CONF_CLIENT_KEY) + current_tls_insecure = current_config.get(CONF_TLS_INSECURE, False) + current_protocol = current_config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL) + advanced_broker_options |= bool( + current_client_id + or current_keepalive != DEFAULT_KEEPALIVE + or current_ca_certificate + or current_client_certificate + or current_client_key + or current_tls_insecure + or current_protocol != DEFAULT_PROTOCOL + or current_config.get(SET_CA_CERT, "off") != "off" + or current_config.get(SET_CLIENT_CERT) + ) + + # Build form + fields[vol.Required(CONF_BROKER, default=current_broker)] = TEXT_SELECTOR + fields[vol.Required(CONF_PORT, default=current_port)] = PORT_SELECTOR + fields[ + vol.Optional( + CONF_USERNAME, + description={"suggested_value": current_user}, + ) + ] = TEXT_SELECTOR + fields[ + vol.Optional( + CONF_PASSWORD, + description={"suggested_value": current_pass}, + ) + ] = PASSWORD_SELECTOR + # show advanced options checkbox if requested + # or when the defaults of advanced options are overridden + if not advanced_broker_options: + fields[ + vol.Optional( + ADVANCED_OPTIONS, + ) + ] = BOOLEAN_SELECTOR + return False + fields[ + vol.Optional( + CONF_CLIENT_ID, + description={"suggested_value": current_client_id}, + ) + ] = TEXT_SELECTOR + fields[ + vol.Optional( + CONF_KEEPALIVE, + description={"suggested_value": current_keepalive}, + ) + ] = KEEPALIVE_SELECTOR + fields[ + vol.Optional( + SET_CLIENT_CERT, + default=current_client_certificate is not None + or current_config.get(SET_CLIENT_CERT) is True, + ) + ] = BOOLEAN_SELECTOR + if ( + current_client_certificate is not None + or current_config.get(SET_CLIENT_CERT) is True + ): + fields[ + vol.Optional( + CONF_CLIENT_CERT, + description={"suggested_value": user_input_basic.get(CONF_CLIENT_CERT)}, + ) + ] = CERT_UPLOAD_SELECTOR + fields[ + vol.Optional( + CONF_CLIENT_KEY, + description={"suggested_value": user_input_basic.get(CONF_CLIENT_KEY)}, + ) + ] = KEY_UPLOAD_SELECTOR + verification_mode = current_config.get(SET_CA_CERT) or ( + "off" + if current_ca_certificate is None + else "auto" + if current_ca_certificate == "auto" + else "custom" + ) + fields[ + vol.Optional( + SET_CA_CERT, + default=verification_mode, + ) + ] = BROKER_VERIFICATION_SELECTOR + if current_ca_certificate is not None or verification_mode == "custom": + fields[ + vol.Optional( + CONF_CERTIFICATE, + user_input_basic.get(CONF_CERTIFICATE), + ) + ] = CA_CERT_UPLOAD_SELECTOR + fields[ + vol.Optional( + CONF_TLS_INSECURE, + description={"suggested_value": current_tls_insecure}, + ) + ] = BOOLEAN_SELECTOR + fields[ + vol.Optional( + CONF_PROTOCOL, + description={"suggested_value": current_protocol}, + ) + ] = PROTOCOL_SELECTOR + + # Show form + return False + + def try_connection( - yaml_config: ConfigType, - broker: str, - port: int, - username: str | None, - password: str | None, - protocol: str = "3.1", + user_input: dict[str, Any], ) -> bool: """Test if we can connect to an MQTT broker.""" # We don't import on the top because some integrations # should be able to optionally rely on MQTT. import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel - # Get the config from configuration.yaml - entry_config = { - CONF_BROKER: broker, - CONF_PORT: port, - CONF_USERNAME: username, - CONF_PASSWORD: password, - CONF_PROTOCOL: protocol, - } - client = MqttClientSetup({**yaml_config, **entry_config}).client + client = MqttClientSetup(user_input).client result: queue.Queue[bool] = queue.Queue(maxsize=1) - def on_connect(client_, userdata, flags, result_code): + def on_connect( + client_: mqtt.Client, userdata: None, flags: dict[str, Any], result_code: int + ) -> None: """Handle connection result.""" result.put(result_code == mqtt.CONNACK_ACCEPTED) client.on_connect = on_connect - client.connect_async(broker, port) + client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT]) client.loop_start() try: @@ -379,3 +700,36 @@ def try_connection( finally: client.disconnect() client.loop_stop() + + +def check_certicate_chain() -> str | None: + """Check the MQTT certificates.""" + if client_certiticate := get_file_path(CONF_CLIENT_CERT): + try: + with open(client_certiticate, "rb") as client_certiticate_file: + load_pem_x509_certificate(client_certiticate_file.read()) + except ValueError: + return "bad_client_cert" + # Check we can serialize the private key file + if private_key := get_file_path(CONF_CLIENT_KEY): + try: + with open(private_key, "rb") as client_key_file: + load_pem_private_key(client_key_file.read(), password=None) + except (TypeError, ValueError): + return "bad_client_key" + # Check the certificate chain + context = SSLContext(PROTOCOL_TLS) + if client_certiticate and private_key: + try: + context.load_cert_chain(client_certiticate, private_key) + except SSLError: + return "bad_client_cert_key" + # try to load the custom CA file + if (ca_cert := get_file_path(CONF_CERTIFICATE)) is None: + return None + + try: + context.load_verify_locations(ca_cert) + except SSLError: + return "bad_certificate" + return None diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index ab685a638021fad1d706901f9fbe3cf22f9ebc73..2be125c2c1209be12d8d1687ddffa0b7785911b3 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -32,6 +32,7 @@ from . import ( sensor as sensor_platform, siren as siren_platform, switch as switch_platform, + update as update_platform, vacuum as vacuum_platform, ) from .const import ( @@ -51,26 +52,28 @@ from .const import ( CONF_WILL_MESSAGE, DEFAULT_BIRTH, DEFAULT_DISCOVERY, + DEFAULT_KEEPALIVE, + DEFAULT_PORT, DEFAULT_PREFIX, + DEFAULT_PROTOCOL, DEFAULT_QOS, DEFAULT_RETAIN, DEFAULT_WILL, - PROTOCOL_31, - PROTOCOL_311, + SUPPORTED_PROTOCOLS, ) from .util import _VALID_QOS_SCHEMA, valid_publish_topic -DEFAULT_PORT = 1883 -DEFAULT_KEEPALIVE = 60 -DEFAULT_PROTOCOL = PROTOCOL_311 DEFAULT_TLS_PROTOCOL = "auto" DEFAULT_VALUES = { CONF_BIRTH_MESSAGE: DEFAULT_BIRTH, CONF_DISCOVERY: DEFAULT_DISCOVERY, + CONF_DISCOVERY_PREFIX: DEFAULT_PREFIX, CONF_PORT: DEFAULT_PORT, + CONF_PROTOCOL: DEFAULT_PROTOCOL, CONF_TLS_VERSION: DEFAULT_TLS_PROTOCOL, CONF_WILL_MESSAGE: DEFAULT_WILL, + CONF_KEEPALIVE: DEFAULT_KEEPALIVE, } PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( @@ -126,6 +129,9 @@ PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( Platform.SWITCH.value: vol.All( cv.ensure_list, [switch_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] ), + Platform.UPDATE.value: vol.All( + cv.ensure_list, [update_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), Platform.VACUUM.value: vol.All( cv.ensure_list, [vacuum_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] ), @@ -148,12 +154,35 @@ MQTT_WILL_BIRTH_SCHEMA = vol.Schema( required=True, ) +CONFIG_SCHEMA_ENTRY = vol.Schema( + { + vol.Optional(CONF_CLIENT_ID): cv.string, + vol.Optional(CONF_KEEPALIVE): vol.All(vol.Coerce(int), vol.Range(min=15)), + vol.Optional(CONF_BROKER): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CERTIFICATE): str, + vol.Inclusive(CONF_CLIENT_KEY, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG): str, + vol.Inclusive( + CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG + ): str, + vol.Optional(CONF_TLS_INSECURE): cv.boolean, + vol.Optional(CONF_TLS_VERSION): vol.Any("auto", "1.0", "1.1", "1.2"), + vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(SUPPORTED_PROTOCOLS)), + vol.Optional(CONF_WILL_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, + vol.Optional(CONF_BIRTH_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, + vol.Optional(CONF_DISCOVERY): cv.boolean, + # discovery_prefix must be a valid publish topic because if no + # state topic is specified, it will be created with the given prefix. + vol.Optional(CONF_DISCOVERY_PREFIX): valid_publish_topic, + } +) + CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( { vol.Optional(CONF_CLIENT_ID): cv.string, - vol.Optional(CONF_KEEPALIVE, default=DEFAULT_KEEPALIVE): vol.All( - vol.Coerce(int), vol.Range(min=15) - ), + vol.Optional(CONF_KEEPALIVE): vol.All(vol.Coerce(int), vol.Range(min=15)), vol.Optional(CONF_BROKER): cv.string, vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_USERNAME): cv.string, @@ -167,27 +196,34 @@ CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( ): cv.isfile, vol.Optional(CONF_TLS_INSECURE): cv.boolean, vol.Optional(CONF_TLS_VERSION): vol.Any("auto", "1.0", "1.1", "1.2"), - vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.All( - cv.string, vol.In([PROTOCOL_31, PROTOCOL_311]) - ), + vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(SUPPORTED_PROTOCOLS)), vol.Optional(CONF_WILL_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, vol.Optional(CONF_BIRTH_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, vol.Optional(CONF_DISCOVERY): cv.boolean, # discovery_prefix must be a valid publish topic because if no # state topic is specified, it will be created with the given prefix. - vol.Optional( - CONF_DISCOVERY_PREFIX, default=DEFAULT_PREFIX - ): valid_publish_topic, + vol.Optional(CONF_DISCOVERY_PREFIX): valid_publish_topic, } ) DEPRECATED_CONFIG_KEYS = [ CONF_BIRTH_MESSAGE, CONF_BROKER, + CONF_CLIENT_ID, CONF_DISCOVERY, + CONF_DISCOVERY_PREFIX, + CONF_KEEPALIVE, CONF_PASSWORD, CONF_PORT, + CONF_PROTOCOL, + CONF_TLS_INSECURE, CONF_TLS_VERSION, CONF_USERNAME, CONF_WILL_MESSAGE, ] + +DEPRECATED_CERTIFICATE_CONFIG_KEYS = [ + CONF_CERTIFICATE, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, +] diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 93410f0c792356851a272a81bf806a1e8d4ee4b5..1dc25c1e78c620224b949a669e69fc008e7b7a8c 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -40,8 +40,17 @@ DEFAULT_ENCODING = "utf-8" DEFAULT_QOS = 0 DEFAULT_PAYLOAD_AVAILABLE = "online" DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" +DEFAULT_PORT = 1883 DEFAULT_RETAIN = False +PROTOCOL_31 = "3.1" +PROTOCOL_311 = "3.1.1" +SUPPORTED_PROTOCOLS = [PROTOCOL_31, PROTOCOL_311] + +DEFAULT_PORT = 1883 +DEFAULT_KEEPALIVE = 60 +DEFAULT_PROTOCOL = PROTOCOL_311 + DEFAULT_BIRTH = { ATTR_TOPIC: DEFAULT_BIRTH_WILL_TOPIC, CONF_PAYLOAD: DEFAULT_PAYLOAD_AVAILABLE, @@ -64,9 +73,6 @@ MQTT_DISCONNECTED = "mqtt_disconnected" PAYLOAD_EMPTY_JSON = "{}" PAYLOAD_NONE = "None" -PROTOCOL_31 = "3.1" -PROTOCOL_311 = "3.1.1" - PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, @@ -85,6 +91,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.UPDATE, Platform.VACUUM, ] @@ -105,5 +112,6 @@ RELOADABLE_PLATFORMS = [ Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.UPDATE, Platform.VACUUM, ] diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 11901f1505438dbe9ec8306f63ae7f29328eb6e6..7d7d4f61c4a0f44b5a3293ec6d467c4a3ffc403c 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -552,7 +552,7 @@ class MqttCover(MqttEntity, CoverEntity): This method is a coroutine. """ await self.async_publish( - self._config.get(CONF_COMMAND_TOPIC), + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_OPEN], self._config[CONF_QOS], self._config[CONF_RETAIN], @@ -573,7 +573,7 @@ class MqttCover(MqttEntity, CoverEntity): This method is a coroutine. """ await self.async_publish( - self._config.get(CONF_COMMAND_TOPIC), + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_CLOSE], self._config[CONF_QOS], self._config[CONF_RETAIN], @@ -594,7 +594,7 @@ class MqttCover(MqttEntity, CoverEntity): This method is a coroutine. """ await self.async_publish( - self._config.get(CONF_COMMAND_TOPIC), + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_STOP], self._config[CONF_QOS], self._config[CONF_RETAIN], @@ -614,7 +614,7 @@ class MqttCover(MqttEntity, CoverEntity): } tilt_payload = self._set_tilt_template(tilt_open_position, variables=variables) await self.async_publish( - self._config.get(CONF_TILT_COMMAND_TOPIC), + self._config[CONF_TILT_COMMAND_TOPIC], tilt_payload, self._config[CONF_QOS], self._config[CONF_RETAIN], @@ -641,7 +641,7 @@ class MqttCover(MqttEntity, CoverEntity): tilt_closed_position, variables=variables ) await self.async_publish( - self._config.get(CONF_TILT_COMMAND_TOPIC), + self._config[CONF_TILT_COMMAND_TOPIC], tilt_payload, self._config[CONF_QOS], self._config[CONF_RETAIN], @@ -670,7 +670,7 @@ class MqttCover(MqttEntity, CoverEntity): tilt = self._set_tilt_template(tilt, variables=variables) await self.async_publish( - self._config.get(CONF_TILT_COMMAND_TOPIC), + self._config[CONF_TILT_COMMAND_TOPIC], tilt, self._config[CONF_QOS], self._config[CONF_RETAIN], @@ -697,7 +697,7 @@ class MqttCover(MqttEntity, CoverEntity): position = self._set_position_template(position, variables=variables) await self.async_publish( - self._config.get(CONF_SET_POSITION_TOPIC), + self._config[CONF_SET_POSITION_TOPIC], position, self._config[CONF_QOS], self._config[CONF_RETAIN], diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 92ad50b7b4a760f3f34010282faee4f6893b5161..84f14d26146d05869b8483b16205e43eb9befe80 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -7,6 +7,7 @@ import functools import logging import re import time +from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_PLATFORM @@ -19,7 +20,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.json import json_loads from homeassistant.helpers.service_info.mqtt import MqttServiceInfo -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.loader import async_get_mqtt from .. import mqtt @@ -62,6 +63,7 @@ SUPPORTED_COMPONENTS = [ "sensor", "switch", "tag", + "update", "vacuum", ] @@ -72,8 +74,8 @@ MQTT_DISCOVERY_DONE = "mqtt_discovery_done_{}" TOPIC_BASE = "~" -class MQTTConfig(dict): - """Dummy class to allow adding attributes.""" +class MQTTDiscoveryPayload(dict[str, Any]): + """Class to hold and MQTT discovery payload and discovery data.""" discovery_data: DiscoveryInfoType @@ -95,7 +97,7 @@ async def async_start( # noqa: C901 mqtt_data = get_mqtt_data(hass) mqtt_integrations = {} - async def async_discovery_message_received(msg): + async def async_discovery_message_received(msg) -> None: """Process the received message.""" mqtt_data.last_discovery = time.time() payload = msg.payload @@ -125,7 +127,7 @@ async def async_start( # noqa: C901 _LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload) return - payload = MQTTConfig(payload) + payload = MQTTDiscoveryPayload(payload) for key in list(payload): abbreviated_key = key @@ -194,7 +196,7 @@ async def async_start( # noqa: C901 await async_process_discovery_payload(component, discovery_id, payload) async def async_process_discovery_payload( - component: str, discovery_id: str, payload: ConfigType + component: str, discovery_id: str, payload: MQTTDiscoveryPayload ) -> None: """Process the payload of a new discovery.""" diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 6fb81deeb4d9f4d45447930395579da7daf44739..d37b15769adf6dafcb34c8e4f744f197c5590751 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mqtt", "requirements": ["paho-mqtt==1.6.1"], - "dependencies": ["http"], + "dependencies": ["file_upload", "http"], "codeowners": ["@emontnemery"], "iot_class": "local_push" } diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index b5c870a196e8804b5e4767e1c1c89c8c4221f48a..6b181f2e4b54868298ba98effa707ec771fcecee 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -34,7 +34,10 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED +from homeassistant.helpers.device_registry import ( + EVENT_DEVICE_REGISTRY_UPDATED, + DeviceEntry, +) from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -74,11 +77,18 @@ from .discovery import ( MQTT_DISCOVERY_DONE, MQTT_DISCOVERY_NEW, MQTT_DISCOVERY_UPDATED, + MQTTDiscoveryPayload, clear_discovery_hash, set_discovery_hash, ) -from .models import MqttValueTemplate, PublishPayloadType, ReceiveMessage +from .models import ( + MqttValueTemplate, + PublishPayloadType, + ReceiveMessage, + ReceivePayloadType, +) from .subscription import ( + EntitySubscription, async_prepare_subscribe_topics, async_subscribe_topics, async_unsubscribe_topics, @@ -222,7 +232,7 @@ MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend( ) -def warn_for_legacy_schema(domain: str) -> Callable: +def warn_for_legacy_schema(domain: str) -> Callable[[ConfigType], ConfigType]: """Warn once when a legacy platform schema is used.""" warned = set() @@ -269,8 +279,8 @@ class SetupEntity(Protocol): hass: HomeAssistant, async_add_entities: AddEntitiesCallback, config: ConfigType, - config_entry: ConfigEntry | None = None, - discovery_data: dict[str, Any] | None = None, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None = None, ) -> None: """Define setup_entities type.""" @@ -281,6 +291,7 @@ async def async_get_platform_config_from_yaml( config_yaml: ConfigType | None = None, ) -> list[ConfigType]: """Return a list of validated configurations for the domain.""" + platform_configs: Any | None mqtt_data = get_mqtt_data(hass) if config_yaml is None: config_yaml = mqtt_data.config @@ -288,19 +299,20 @@ async def async_get_platform_config_from_yaml( return [] if not (platform_configs := config_yaml.get(platform_domain)): return [] + assert isinstance(platform_configs, list) return platform_configs async def async_setup_entry_helper( hass: HomeAssistant, domain: str, - async_setup: partial[Coroutine[HomeAssistant, str, None]], + async_setup: partial[Coroutine[Any, Any, None]], discovery_schema: vol.Schema, ) -> None: """Set up entity, automation or tag creation dynamically through MQTT discovery.""" mqtt_data = get_mqtt_data(hass) - async def async_discover(discovery_payload): + async def async_discover(discovery_payload: MQTTDiscoveryPayload) -> None: """Discover and add an MQTT entity, automation or tag.""" if not mqtt_config_entry_enabled(hass): _LOGGER.warning( @@ -312,10 +324,10 @@ async def async_setup_entry_helper( return discovery_data = discovery_payload.discovery_data try: - config = discovery_schema(discovery_payload) + config: DiscoveryInfoType = discovery_schema(discovery_payload) await async_setup(config, discovery_data=discovery_data) except Exception: - discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] + discovery_hash: tuple[str, str] = discovery_data[ATTR_DISCOVERY_HASH] clear_discovery_hash(hass, discovery_hash) async_dispatcher_send( hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None @@ -357,7 +369,7 @@ async def async_setup_entry_helper( async def async_setup_platform_helper( hass: HomeAssistant, platform_domain: str, - config: ConfigType | DiscoveryInfoType, + config: ConfigType, async_add_entities: AddEntitiesCallback, async_setup_entities: SetupEntity, ) -> None: @@ -381,7 +393,9 @@ async def async_setup_platform_helper( await async_setup_entities(hass, async_add_entities, config, config_entry) -def init_entity_id_from_config(hass, entity, config, entity_id_format): +def init_entity_id_from_config( + hass: HomeAssistant, entity: Entity, config: ConfigType, entity_id_format: str +) -> None: """Set entity_id from object_id if defined in config.""" if CONF_OBJECT_ID in config: entity.entity_id = async_generate_entity_id( @@ -394,10 +408,10 @@ class MqttAttributes(Entity): _attributes_extra_blocked: frozenset[str] = frozenset() - def __init__(self, config: dict) -> None: + def __init__(self, config: ConfigType) -> None: """Initialize the JSON attributes mixin.""" self._attributes: dict[str, Any] | None = None - self._attributes_sub_state = None + self._attributes_sub_state: dict[str, EntitySubscription] = {} self._attributes_config = config async def async_added_to_hass(self) -> None: @@ -406,16 +420,16 @@ class MqttAttributes(Entity): self._attributes_prepare_subscribe_topics() await self._attributes_subscribe_topics() - def attributes_prepare_discovery_update(self, config: dict): + def attributes_prepare_discovery_update(self, config: DiscoveryInfoType) -> None: """Handle updated discovery message.""" self._attributes_config = config self._attributes_prepare_subscribe_topics() - async def attributes_discovery_update(self, config: dict): + async def attributes_discovery_update(self, config: DiscoveryInfoType) -> None: """Handle updated discovery message.""" await self._attributes_subscribe_topics() - def _attributes_prepare_subscribe_topics(self): + def _attributes_prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" attr_tpl = MqttValueTemplate( self._attributes_config.get(CONF_JSON_ATTRS_TEMPLATE), entity=self @@ -458,11 +472,11 @@ class MqttAttributes(Entity): }, ) - async def _attributes_subscribe_topics(self): + async def _attributes_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await async_subscribe_topics(self.hass, self._attributes_sub_state) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Unsubscribe when removed.""" self._attributes_sub_state = async_unsubscribe_topics( self.hass, self._attributes_sub_state @@ -477,11 +491,11 @@ class MqttAttributes(Entity): class MqttAvailability(Entity): """Mixin used for platforms that report availability.""" - def __init__(self, config: dict) -> None: + def __init__(self, config: ConfigType) -> None: """Initialize the availability mixin.""" - self._availability_sub_state = None - self._available: dict = {} - self._available_latest = False + self._availability_sub_state: dict[str, EntitySubscription] = {} + self._available: dict[str, str | bool] = {} + self._available_latest: bool = False self._availability_setup_from_config(config) async def async_added_to_hass(self) -> None: @@ -498,18 +512,18 @@ class MqttAvailability(Entity): ) ) - def availability_prepare_discovery_update(self, config: dict): + def availability_prepare_discovery_update(self, config: DiscoveryInfoType) -> None: """Handle updated discovery message.""" self._availability_setup_from_config(config) self._availability_prepare_subscribe_topics() - async def availability_discovery_update(self, config: dict): + async def availability_discovery_update(self, config: DiscoveryInfoType) -> None: """Handle updated discovery message.""" await self._availability_subscribe_topics() - def _availability_setup_from_config(self, config): + def _availability_setup_from_config(self, config: ConfigType) -> None: """(Re)Setup.""" - self._avail_topics = {} + self._avail_topics: dict[str, dict[str, Any]] = {} if CONF_AVAILABILITY_TOPIC in config: self._avail_topics[config[CONF_AVAILABILITY_TOPIC]] = { CONF_PAYLOAD_AVAILABLE: config[CONF_PAYLOAD_AVAILABLE], @@ -518,6 +532,7 @@ class MqttAvailability(Entity): } if CONF_AVAILABILITY in config: + avail: dict[str, Any] for avail in config[CONF_AVAILABILITY]: self._avail_topics[avail[CONF_TOPIC]] = { CONF_PAYLOAD_AVAILABLE: avail[CONF_PAYLOAD_AVAILABLE], @@ -533,7 +548,7 @@ class MqttAvailability(Entity): self._avail_config = config - def _availability_prepare_subscribe_topics(self): + def _availability_prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @callback @@ -541,6 +556,7 @@ class MqttAvailability(Entity): def availability_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT availability message.""" topic = msg.topic + payload: ReceivePayloadType payload = self._avail_topics[topic][CONF_AVAILABILITY_TEMPLATE](msg.payload) if payload == self._avail_topics[topic][CONF_PAYLOAD_AVAILABLE]: self._available[topic] = True @@ -555,7 +571,7 @@ class MqttAvailability(Entity): topic: (self._available[topic] if topic in self._available else False) for topic in self._avail_topics } - topics = { + topics: dict[str, dict[str, Any]] = { f"availability_{topic}": { "topic": topic, "msg_callback": availability_message_received, @@ -571,17 +587,17 @@ class MqttAvailability(Entity): topics, ) - async def _availability_subscribe_topics(self): + async def _availability_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await async_subscribe_topics(self.hass, self._availability_sub_state) @callback - def async_mqtt_connect(self): + def async_mqtt_connect(self) -> None: """Update state on connection/disconnection to MQTT broker.""" if not self.hass.is_stopping: self.async_write_ha_state() - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Unsubscribe when removed.""" self._availability_sub_state = async_unsubscribe_topics( self.hass, self._availability_sub_state @@ -628,12 +644,12 @@ async def cleanup_device_registry( ) -def get_discovery_hash(discovery_data: dict) -> tuple[str, str]: +def get_discovery_hash(discovery_data: DiscoveryInfoType) -> tuple[str, str]: """Get the discovery hash from the discovery data.""" return discovery_data[ATTR_DISCOVERY_HASH] -def send_discovery_done(hass: HomeAssistant, discovery_data: dict) -> None: +def send_discovery_done(hass: HomeAssistant, discovery_data: DiscoveryInfoType) -> None: """Acknowledge a discovery message has been handled.""" discovery_hash = get_discovery_hash(discovery_data) async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None) @@ -641,7 +657,7 @@ def send_discovery_done(hass: HomeAssistant, discovery_data: dict) -> None: def stop_discovery_updates( hass: HomeAssistant, - discovery_data: dict, + discovery_data: DiscoveryInfoType, remove_discovery_updated: Callable[[], None] | None = None, ) -> None: """Stop discovery updates of being sent.""" @@ -652,7 +668,9 @@ def stop_discovery_updates( clear_discovery_hash(hass, discovery_hash) -async def async_remove_discovery_payload(hass: HomeAssistant, discovery_data: dict): +async def async_remove_discovery_payload( + hass: HomeAssistant, discovery_data: DiscoveryInfoType +) -> None: """Clear retained discovery topic in broker to avoid rediscovery after a restart of HA.""" discovery_topic = discovery_data[ATTR_DISCOVERY_TOPIC] await async_publish(hass, discovery_topic, "", retain=True) @@ -660,7 +678,7 @@ async def async_remove_discovery_payload(hass: HomeAssistant, discovery_data: di async def async_clear_discovery_topic_if_entity_removed( hass: HomeAssistant, - discovery_data: dict[str, Any], + discovery_data: DiscoveryInfoType, event: Event, ) -> None: """Clear the discovery topic if the entity is removed.""" @@ -675,7 +693,7 @@ class MqttDiscoveryDeviceUpdate: def __init__( self, hass: HomeAssistant, - discovery_data: dict, + discovery_data: DiscoveryInfoType, device_id: str | None, config_entry: ConfigEntry, log_name: str, @@ -718,7 +736,7 @@ class MqttDiscoveryDeviceUpdate: async def async_discovery_update( self, - discovery_payload: DiscoveryInfoType | None, + discovery_payload: MQTTDiscoveryPayload, ) -> None: """Handle discovery update.""" discovery_hash = get_discovery_hash(self._discovery_data) @@ -789,7 +807,7 @@ class MqttDiscoveryDeviceUpdate: self.hass, self._device_id, self._config_entry_id ) - async def async_update(self, discovery_data: dict) -> None: + async def async_update(self, discovery_data: MQTTDiscoveryPayload) -> None: """Handle the update of platform specific parts, extend to the platform.""" @abstractmethod @@ -803,13 +821,14 @@ class MqttDiscoveryUpdate(Entity): def __init__( self, hass: HomeAssistant, - discovery_data: dict | None, - discovery_update: Callable | None = None, + discovery_data: DiscoveryInfoType | None, + discovery_update: Callable[[MQTTDiscoveryPayload], Coroutine[Any, Any, None]] + | None = None, ) -> None: """Initialize the discovery update mixin.""" self._discovery_data = discovery_data self._discovery_update = discovery_update - self._remove_discovery_updated: Callable | None = None + self._remove_discovery_updated: Callable[[], None] | None = None self._removed_from_hass = False if discovery_data is None: return @@ -823,11 +842,13 @@ class MqttDiscoveryUpdate(Entity): """Subscribe to discovery updates.""" await super().async_added_to_hass() self._removed_from_hass = False - discovery_hash = ( + discovery_hash: tuple[str, str] | None = ( self._discovery_data[ATTR_DISCOVERY_HASH] if self._discovery_data else None ) - async def _async_remove_state_and_registry_entry(self) -> None: + async def _async_remove_state_and_registry_entry( + self: MqttDiscoveryUpdate, + ) -> None: """Remove entity's state and entity registry entry. Remove entity from entity registry if it is registered, this also removes the state. @@ -842,13 +863,15 @@ class MqttDiscoveryUpdate(Entity): else: await self.async_remove(force_remove=True) - async def discovery_callback(payload): + async def discovery_callback(payload: MQTTDiscoveryPayload) -> None: """Handle discovery update.""" _LOGGER.info( "Got update for entity with hash: %s '%s'", discovery_hash, payload, ) + assert self._discovery_data + old_payload: DiscoveryInfoType old_payload = self._discovery_data[ATTR_DISCOVERY_PAYLOAD] debug_info.update_entity_discovery_data(self.hass, payload, self.entity_id) if not payload: @@ -923,39 +946,43 @@ class MqttDiscoveryUpdate(Entity): self._removed_from_hass = True -def device_info_from_config(config) -> DeviceInfo | None: +def device_info_from_specifications( + specifications: dict[str, Any] | None +) -> DeviceInfo | None: """Return a device description for device registry.""" - if not config: + if not specifications: return None info = DeviceInfo( - identifiers={(DOMAIN, id_) for id_ in config[CONF_IDENTIFIERS]}, - connections={(conn_[0], conn_[1]) for conn_ in config[CONF_CONNECTIONS]}, + identifiers={(DOMAIN, id_) for id_ in specifications[CONF_IDENTIFIERS]}, + connections={ + (conn_[0], conn_[1]) for conn_ in specifications[CONF_CONNECTIONS] + }, ) - if CONF_MANUFACTURER in config: - info[ATTR_MANUFACTURER] = config[CONF_MANUFACTURER] + if CONF_MANUFACTURER in specifications: + info[ATTR_MANUFACTURER] = specifications[CONF_MANUFACTURER] - if CONF_MODEL in config: - info[ATTR_MODEL] = config[CONF_MODEL] + if CONF_MODEL in specifications: + info[ATTR_MODEL] = specifications[CONF_MODEL] - if CONF_NAME in config: - info[ATTR_NAME] = config[CONF_NAME] + if CONF_NAME in specifications: + info[ATTR_NAME] = specifications[CONF_NAME] - if CONF_HW_VERSION in config: - info[ATTR_HW_VERSION] = config[CONF_HW_VERSION] + if CONF_HW_VERSION in specifications: + info[ATTR_HW_VERSION] = specifications[CONF_HW_VERSION] - if CONF_SW_VERSION in config: - info[ATTR_SW_VERSION] = config[CONF_SW_VERSION] + if CONF_SW_VERSION in specifications: + info[ATTR_SW_VERSION] = specifications[CONF_SW_VERSION] - if CONF_VIA_DEVICE in config: - info[ATTR_VIA_DEVICE] = (DOMAIN, config[CONF_VIA_DEVICE]) + if CONF_VIA_DEVICE in specifications: + info[ATTR_VIA_DEVICE] = (DOMAIN, specifications[CONF_VIA_DEVICE]) - if CONF_SUGGESTED_AREA in config: - info[ATTR_SUGGESTED_AREA] = config[CONF_SUGGESTED_AREA] + if CONF_SUGGESTED_AREA in specifications: + info[ATTR_SUGGESTED_AREA] = specifications[CONF_SUGGESTED_AREA] - if CONF_CONFIGURATION_URL in config: - info[ATTR_CONFIGURATION_URL] = config[CONF_CONFIGURATION_URL] + if CONF_CONFIGURATION_URL in specifications: + info[ATTR_CONFIGURATION_URL] = specifications[CONF_CONFIGURATION_URL] return info @@ -963,19 +990,21 @@ def device_info_from_config(config) -> DeviceInfo | None: class MqttEntityDeviceInfo(Entity): """Mixin used for mqtt platforms that support the device registry.""" - def __init__(self, device_config: ConfigType | None, config_entry=None) -> None: + def __init__( + self, specifications: dict[str, Any] | None, config_entry: ConfigEntry + ) -> None: """Initialize the device mixin.""" - self._device_config = device_config + self._device_specifications = specifications self._config_entry = config_entry - def device_info_discovery_update(self, config: dict): + def device_info_discovery_update(self, config: DiscoveryInfoType) -> None: """Handle updated discovery message.""" - self._device_config = config.get(CONF_DEVICE) + self._device_specifications = config.get(CONF_DEVICE) device_registry = dr.async_get(self.hass) config_entry_id = self._config_entry.entry_id device_info = self.device_info - if config_entry_id is not None and device_info is not None: + if device_info is not None: device_registry.async_get_or_create( config_entry_id=config_entry_id, **device_info ) @@ -983,7 +1012,7 @@ class MqttEntityDeviceInfo(Entity): @property def device_info(self) -> DeviceInfo | None: """Return a device description for device registry.""" - return device_info_from_config(self._device_config) + return device_info_from_specifications(self._device_specifications) class MqttEntity( @@ -997,12 +1026,18 @@ class MqttEntity( _attr_should_poll = False _entity_id_format: str - def __init__(self, hass, config, config_entry, discovery_data): + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None, + ) -> None: """Init the MQTT Entity.""" self.hass = hass - self._config = config - self._unique_id = config.get(CONF_UNIQUE_ID) - self._sub_state = None + self._config: ConfigType = config + self._unique_id: str | None = config.get(CONF_UNIQUE_ID) + self._sub_state: dict[str, EntitySubscription] = {} # Load config self._setup_from_config(self._config) @@ -1016,14 +1051,14 @@ class MqttEntity( MqttDiscoveryUpdate.__init__(self, hass, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, config.get(CONF_DEVICE), config_entry) - def _init_entity_id(self): + def _init_entity_id(self) -> None: """Set entity_id from object_id if defined in config.""" init_entity_id_from_config( self.hass, self, self._config, self._entity_id_format ) @final - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" await super().async_added_to_hass() self._prepare_subscribe_topics() @@ -1032,15 +1067,15 @@ class MqttEntity( if self._discovery_data is not None: send_discovery_done(self.hass, self._discovery_data) - async def mqtt_async_added_to_hass(self): + async def mqtt_async_added_to_hass(self) -> None: """Call before the discovery message is acknowledged. To be extended by subclasses. """ - async def discovery_update(self, discovery_payload): + async def discovery_update(self, discovery_payload: MQTTDiscoveryPayload) -> None: """Handle updated discovery message.""" - config = self.config_schema()(discovery_payload) + config: DiscoveryInfoType = self.config_schema()(discovery_payload) self._config = config self._setup_from_config(self._config) @@ -1056,7 +1091,7 @@ class MqttEntity( await self._subscribe_topics() self.async_write_ha_state() - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Unsubscribe when removed.""" self._sub_state = subscription.async_unsubscribe_topics( self.hass, self._sub_state @@ -1073,7 +1108,7 @@ class MqttEntity( qos: int = 0, retain: bool = False, encoding: str = DEFAULT_ENCODING, - ): + ) -> None: """Publish message to an MQTT topic.""" log_message(self.hass, self.entity_id, topic, payload, qos, retain) await async_publish( @@ -1087,18 +1122,18 @@ class MqttEntity( @staticmethod @abstractmethod - def config_schema(): + def config_schema() -> vol.Schema: """Return the config schema.""" - def _setup_from_config(self, config): + def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" @abstractmethod - def _prepare_subscribe_topics(self): + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @abstractmethod - async def _subscribe_topics(self): + async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @property @@ -1112,17 +1147,17 @@ class MqttEntity( return self._config.get(CONF_ENTITY_CATEGORY) @property - def icon(self): + def icon(self) -> str | None: """Return icon of the entity if any.""" return self._config.get(CONF_ICON) @property - def name(self): + def name(self) -> str | None: """Return the name of the device if any.""" return self._config.get(CONF_NAME) @property - def unique_id(self): + def unique_id(self) -> str | None: """Return a unique ID.""" return self._unique_id @@ -1136,13 +1171,13 @@ def update_device( if CONF_DEVICE not in config: return None - device = None + device: DeviceEntry | None = None device_registry = dr.async_get(hass) config_entry_id = config_entry.entry_id - device_info = device_info_from_config(config[CONF_DEVICE]) + device_info = device_info_from_specifications(config[CONF_DEVICE]) if config_entry_id is not None and device_info is not None: - update_device_info = cast(dict, device_info) + update_device_info = cast(dict[str, Any], device_info) update_device_info["config_entry_id"] = config_entry_id device = device_registry.async_get_or_create(**update_device_info) @@ -1154,7 +1189,7 @@ def async_removed_from_device( hass: HomeAssistant, event: Event, mqtt_device_id: str, config_entry_id: str ) -> bool: """Check if the passed event indicates MQTT was removed from a device.""" - device_id = event.data["device_id"] + device_id: str = event.data["device_id"] if event.data["action"] not in ("remove", "update"): return False diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index f2f30419b4c66b93281f1a5208c5c64eb172dba6..363956cc7324086f1803ee5d9ce85cf5cde7e339 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from .client import MQTT, Subscription from .debug_info import TimestampedPublishMessage from .device_trigger import Trigger - from .discovery import MQTTConfig + from .discovery import MQTTDiscoveryPayload from .tag import MQTTTagScanner _SENTINEL = object() @@ -86,7 +86,7 @@ class TriggerDebugInfo(TypedDict): class PendingDiscovered(TypedDict): """Pending discovered items.""" - pending: deque[MQTTConfig] + pending: deque[MQTTDiscoveryPayload] unsub: CALLBACK_TYPE diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index e11f0a685e7bf82c4705c6823eba7eb0d582809c..4799b45e631247462ae835ab3aca8f2a912607a2 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -3,6 +3,10 @@ "deprecated_yaml": { "title": "Your manually configured MQTT {platform}(s) needs attention", "description": "Manually configured MQTT {platform}(s) found under platform key `{platform}`.\n\nPlease move the configuration to the `mqtt` integration key and restart Home Assistant to fix this issue. See the [documentation]({more_info_url}), for more information." + }, + "deprecated_yaml_broker_settings": { + "title": "Deprecated MQTT settings found in `configuration.yaml`", + "description": "The following settings found in `configuration.yaml` were migrated to MQTT config entry and will now override the settings in `configuration.yaml`:\n`{deprecated_settings}`\n\nPlease remove these settings from `configuration.yaml` and restart Home Assistant to fix this issue. See the [documentation]({more_info_url}), for more information." } }, "config": { @@ -14,7 +18,16 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "discovery": "Enable discovery" + "advanced_options": "Advanced options", + "certificate": "Path to custom CA certificate file", + "client_id": "Client ID (leave empty to randomly generated one)", + "client_cert": "Path to a client certificate file", + "client_key": "Path to a private key file", + "keepalive": "The time between sending keep alive messages", + "tls_insecure": "Ignore broker certificate validation", + "protocol": "MQTT protocol", + "set_ca_cert": "Broker certificate validation", + "set_client_cert": "Use a client certificate" } }, "hassio_confirm": { @@ -30,7 +43,15 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "bad_birth": "Invalid birth topic", + "bad_will": "Invalid will topic", + "bad_discovery_prefix": "Invalid discovery prefix", + "bad_certificate": "The CA certificate is invalid", + "bad_client_cert": "Invalid client certiticate, ensure a PEM coded file is supplied", + "bad_client_key": "Invalid private key, ensure a PEM coded file is supplied without password", + "bad_client_cert_key": "Client certificate and private are no valid pair", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_inclusion": "The client certificate and private key must be configurered together" } }, "device_automation": { @@ -64,14 +85,25 @@ "broker": "Broker", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "advanced_options": "Advanced options", + "certificate": "Upload custom CA certificate file", + "client_id": "Client ID (leave empty to randomly generated one)", + "client_cert": "Upload client certificate file", + "client_key": "Upload private key file", + "keepalive": "The time between sending keep alive messages", + "tls_insecure": "Ignore broker certificate validation", + "protocol": "MQTT protocol", + "set_ca_cert": "Broker certificate validation", + "set_client_cert": "Use a client certificate" } }, "options": { "title": "MQTT options", - "description": "Discovery - If discovery is enabled (recommended), Home Assistant will automatically discover devices and entities which publish their configuration on the MQTT broker. If discovery is disabled, all configuration must be done manually.\nBirth message - The birth message will be sent each time Home Assistant (re)connects to the MQTT broker.\nWill message - The will message will be sent each time Home Assistant loses its connection to the broker, both in case of a clean (e.g. Home Assistant shutting down) and in case of an unclean (e.g. Home Assistant crashing or losing its network connection) disconnect.", + "description": "Discovery - If discovery is enabled (recommended), Home Assistant will automatically discover devices and entities which publish their configuration on the MQTT broker. If discovery is disabled, all configuration must be done manually.\nDiscovery prefix - The prefix a configuration topic for automatic discovery must start with.\nBirth message - The birth message will be sent each time Home Assistant (re)connects to the MQTT broker.\nWill message - The will message will be sent each time Home Assistant loses its connection to the broker, both in case of a clean (e.g. Home Assistant shutting down) and in case of an unclean (e.g. Home Assistant crashing or losing its network connection) disconnect.", "data": { "discovery": "Enable discovery", + "discovery_prefix": "Discovery prefix", "birth_enable": "Enable birth message", "birth_topic": "Birth message topic", "birth_payload": "Birth message payload", @@ -86,9 +118,15 @@ } }, "error": { + "bad_birth": "Invalid birth topic", + "bad_will": "Invalid will topic", + "bad_discovery_prefix": "Invalid discovery prefix", + "bad_certificate": "The CA certificate is invalid", + "bad_client_cert": "Invalid client certiticate, ensure a PEM coded file is supplied", + "bad_client_key": "Invalid private key, ensure a PEM coded file is supplied without password", + "bad_client_cert_key": "Client certificate and private are no valid pair", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "bad_birth": "Invalid birth topic.", - "bad_will": "Invalid will topic." + "invalid_inclusion": "The client certificate and private key must be configured together" } } } diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 05f7f3934ee7d3e3843fc8974ed3ada30a44521f..87f5d3882bb36e12e442b4559349f4461833e1e1 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -21,7 +21,7 @@ class EntitySubscription: hass: HomeAssistant = attr.ib() topic: str = attr.ib() message_callback: MessageCallbackType = attr.ib() - subscribe_task: Coroutine | None = attr.ib() + subscribe_task: Coroutine[Any, Any, Callable[[], None]] | None = attr.ib() unsubscribe_callback: Callable[[], None] | None = attr.ib() qos: int = attr.ib(default=0) encoding: str = attr.ib(default="utf-8") @@ -53,7 +53,7 @@ class EntitySubscription: hass, self.topic, self.message_callback, self.qos, self.encoding ) - async def subscribe(self): + async def subscribe(self) -> None: """Subscribe to a topic.""" if not self.subscribe_task: return diff --git a/homeassistant/components/mqtt/translations/bg.json b/homeassistant/components/mqtt/translations/bg.json index 93b1d77dcfaaaa448e4b78f7421bdb47460b08fe..f99f120d9516ef6c3a564f628b9da77e73b961bf 100644 --- a/homeassistant/components/mqtt/translations/bg.json +++ b/homeassistant/components/mqtt/translations/bg.json @@ -5,15 +5,18 @@ "single_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 MQTT." }, "error": { + "bad_certificate": "CA \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044a\u0442 \u0435 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d", "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0431\u0440\u043e\u043a\u0435\u0440\u0430." }, "step": { "broker": { "data": { + "advanced_options": "\u0420\u0430\u0437\u0448\u0438\u0440\u0435\u043d\u0438 \u043e\u043f\u0446\u0438\u0438", "broker": "\u0411\u0440\u043e\u043a\u0435\u0440", "discovery": "\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 \u043e\u0442\u043a\u0440\u0438\u0432\u0430\u043d\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "port": "\u041f\u043e\u0440\u0442", + "protocol": "MQTT \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b", "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" }, "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u0442\u0430 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0412\u0430\u0448\u0438\u044f MQTT \u0431\u0440\u043e\u043a\u0435\u0440." @@ -36,13 +39,16 @@ }, "options": { "error": { + "bad_certificate": "CA \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044a\u0442 \u0435 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d", "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, "step": { "broker": { "data": { + "advanced_options": "\u0420\u0430\u0437\u0448\u0438\u0440\u0435\u043d\u0438 \u043e\u043f\u0446\u0438\u0438", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "port": "\u041f\u043e\u0440\u0442", + "protocol": "MQTT \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b", "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" } } diff --git a/homeassistant/components/mqtt/translations/ca.json b/homeassistant/components/mqtt/translations/ca.json index 43ab994f4d8bd265e9f19cb38b1e15c9bff1396e..13c91abe856919f25be47bafb6eedceb40931db3 100644 --- a/homeassistant/components/mqtt/translations/ca.json +++ b/homeassistant/components/mqtt/translations/ca.json @@ -5,15 +5,33 @@ "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." }, "error": { - "cannot_connect": "Ha fallat la connexi\u00f3" + "bad_birth": "T\u00f2pic del missatge de naixement ('birth') inv\u00e0lid", + "bad_certificate": "El certificat CA \u00e9s inv\u00e0lid", + "bad_client_cert": "Certificat de client inv\u00e0lid, assegura't que la codificaci\u00f3 del fitxer sigui PEM", + "bad_client_cert_key": "El certificat de client i la clau privada no formen una parella v\u00e0lida", + "bad_client_key": "Clau privada inv\u00e0lida, assegura't que la codificaci\u00f3 del fitxer sigui PEM i sense contrasenya", + "bad_discovery_prefix": "Prefix de descobriment inv\u00e0lid", + "bad_will": "T\u00f2pic del missatge d'\u00faltima voluntat ('will') inv\u00e0lid", + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_inclusion": "El certificat de client i la clau privada s'han de configurar conjuntament" }, "step": { "broker": { "data": { + "advanced_options": "Opcions avan\u00e7ades", "broker": "Broker", + "certificate": "Ruta a un fitxer de certificat CA personalitzat", + "client_cert": "Ruta a un fitxer de certificat de client", + "client_id": "ID de client (deixa-ho buit per generar-lo aleat\u00f2riament)", + "client_key": "Ruta a un fitxer de clau privada", "discovery": "Habilita el descobriment autom\u00e0tic", + "keepalive": "Temps entre enviaments de missatges de manteniment viu ('keep alive')", "password": "Contrasenya", "port": "Port", + "protocol": "Protocol MQTT", + "set_ca_cert": "Validaci\u00f3 del certificat del 'broker'", + "set_client_cert": "Utilitza un certificat de client", + "tls_insecure": "Ignora la validaci\u00f3 del certificat del 'broker'", "username": "Nom d'usuari" }, "description": "Introdueix la informaci\u00f3 de connexi\u00f3 del teu broker MQTT." @@ -53,20 +71,40 @@ "deprecated_yaml": { "description": "S'ha trobat el MQTT {platform}(s) sota el codi de la integraci\u00f3 `{platform}`.\n\nSi us plau, moveu la configuraci\u00f3 a la integraci\u00f3 `mqtt`i reinicieu el Home Assistant per solucionar aquesta incid\u00e8ncia. Vegeu la [documentaci\u00f3]({more_info_url}) per a m\u00e9s informaci\u00f3.", "title": "El MQTT {platform}(s) configurat manualment necessita la vostra atenci\u00f3" + }, + "deprecated_yaml_broker_settings": { + "description": "La configuraci\u00f3 seg\u00fcent que s'ha trobat a `configuration.yaml` ha estat migrada l'entrada de configuraci\u00f3 d'MQTT. Els par\u00e0metres de `configuration.yaml` ja no s'utilitzen.\n`{deprecated_settings}` \n\nElimina la configuraci\u00f3 MQTT de `configuration.yaml` i reinicia Home Assistant per solucionar aquest problema. Consulta la [documentaci\u00f3]({more_info_url}), per a m\u00e9s informaci\u00f3.", + "title": "S'han trobat par\u00e0metres de configuraci\u00f3 MQTT obsolets a `configuration.yaml`" } }, "options": { "error": { - "bad_birth": "Topic del missatge de naixement inv\u00e0lid.", - "bad_will": "Topic missatge d'\u00faltima voluntat inv\u00e0lid.", - "cannot_connect": "Ha fallat la connexi\u00f3" + "bad_birth": "T\u00f2pic del missatge de naixement ('birth') inv\u00e0lid", + "bad_certificate": "El certificat CA \u00e9s inv\u00e0lid", + "bad_client_cert": "Certificat de client inv\u00e0lid, assegura't que la codificaci\u00f3 del fitxer sigui PEM", + "bad_client_cert_key": "El certificat de client i la clau privada no formen una parella v\u00e0lida", + "bad_client_key": "Clau privada inv\u00e0lida, assegura't que la codificaci\u00f3 del fitxer sigui PEM i sense contrasenya", + "bad_discovery_prefix": "Prefix de descobriment inv\u00e0lid", + "bad_will": "T\u00f2pic del missatge d'\u00faltima voluntat ('will') inv\u00e0lid", + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_inclusion": "El certificat de client i la clau privada s'han de configurar conjuntament" }, "step": { "broker": { "data": { + "advanced_options": "Opcions avan\u00e7ades", "broker": "Broker", + "certificate": "Puja un fitxer de certificat CA personalitzat", + "client_cert": "Puja un fitxer de certificat de client", + "client_id": "ID de client (deixa-ho buit per generar-lo aleat\u00f2riament)", + "client_key": "Puja un fitxer de clau privada", + "keepalive": "Temps entre enviaments de missatges de manteniment viu ('keep alive')", "password": "Contrasenya", "port": "Port", + "protocol": "Protocol MQTT", + "set_ca_cert": "Validaci\u00f3 del certificat del 'broker'", + "set_client_cert": "Utilitza un certificat de client", + "tls_insecure": "Ignora la validaci\u00f3 del certificat del 'broker'", "username": "Nom d'usuari" }, "description": "Introdueix la informaci\u00f3 de connexi\u00f3 del teu broker MQTT.", @@ -80,13 +118,14 @@ "birth_retain": "Retenci\u00f3 del missatge de naixement", "birth_topic": "Topic del missatge de naixement", "discovery": "Activar descobriment", + "discovery_prefix": "Prefix de descobriment", "will_enable": "Activa el missatge d'\u00faltima voluntat", "will_payload": "Dades (payload) del missatge d'\u00faltima voluntat", "will_qos": "QoS del missatge d'\u00faltima voluntat", "will_retain": "Retenci\u00f3 del missatge d'\u00faltima voluntat", "will_topic": "Topic del missatge d'\u00faltima voluntat" }, - "description": "Descobriment - Si est\u00e0 activat (recomanat), Home Assistant descobrir\u00e0 autom\u00e0ticament dispositius i entitats que publiquin la seva configuraci\u00f3 al broker MQTT. Si est\u00e0 desactivat, les configuracions s'han de fer manualment.\nMissatge de naixement - S'enviar\u00e0 cada vegada que Home Assistant \u00e9s connecti al broker MQTT.\nMissatge d'\u00faltima voluntat - S'enviar\u00e0 cada vegada que Home Assistant perdi la connexi\u00f3 amb el broker, tant si \u00e9s una desconnexi\u00f3 neta (per exemple si s'apaga Home Assistant) com si \u00e9s una desconnexi\u00f3 dolenta (per exemple si Home Assistant falla o perd la connexi\u00f3 a Internet).", + "description": "Descobriment - Si est\u00e0 activat (recomanat), Home Assistant descobrir\u00e0 autom\u00e0ticament dispositius i entitats que publiquin la seva configuraci\u00f3 al 'broker' MQTT. Si est\u00e0 desactivat, les configuracions s'han de fer manualment.\nPrefix de descobriment - Prefix del t\u00f2pics de configuraci\u00f3 a utilitzar per al descobriment autom\u00e0tic.\nMissatge de naixement - S'enviar\u00e0 cada vegada que Home Assistant es connecti al 'broker' MQTT.\nMissatge d'\u00faltima voluntat - S'enviar\u00e0 cada vegada que Home Assistant perdi la connexi\u00f3 amb el 'broker', tant si \u00e9s una desconnexi\u00f3 neta (per exemple si s'apaga Home Assistant) com si \u00e9s una desconnexi\u00f3 dolenta (per exemple si Home Assistant falla o perd la connexi\u00f3 a Internet).", "title": "Opcions d'MQTT" } } diff --git a/homeassistant/components/mqtt/translations/de.json b/homeassistant/components/mqtt/translations/de.json index 8a0171a6f85df9429b066b8b554d5b71598bb22a..b193f18b39e056125c38df0c860d4eb6ec7fec05 100644 --- a/homeassistant/components/mqtt/translations/de.json +++ b/homeassistant/components/mqtt/translations/de.json @@ -5,15 +5,33 @@ "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { - "cannot_connect": "Verbindung fehlgeschlagen" + "bad_birth": "Ung\u00fcltiges \u201eBirth\u201c-Thema", + "bad_certificate": "Das CA-Zertifikat ist ung\u00fcltig", + "bad_client_cert": "Ung\u00fcltiges Client-Zertifikat. Stelle sicher, dass eine PEM-codierte Datei bereitgestellt wird", + "bad_client_cert_key": "Client-Zertifikat und privates Zertifikat sind kein g\u00fcltiges Paar", + "bad_client_key": "Ung\u00fcltiger privater Schl\u00fcssel. Stelle sicher, dass eine PEM-codierte Datei ohne Passwort bereitgestellt wird", + "bad_discovery_prefix": "Ung\u00fcltiges Discovery-Pr\u00e4fix", + "bad_will": "Ung\u00fcltiges \u201eWill\u201c-Thema", + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_inclusion": "Das Client-Zertifikat und der private Schl\u00fcssel m\u00fcssen gemeinsam konfiguriert werden" }, "step": { "broker": { "data": { + "advanced_options": "Erweiterte Optionen", "broker": "Server", + "certificate": "Pfad zur benutzerdefinierten CA-Zertifikatsdatei", + "client_cert": "Pfad zur Client-Zertifikatsdatei", + "client_id": "Client-ID (leer lassen, um eine zuf\u00e4llig generierte zu erhalten)", + "client_key": "Pfad zur privaten Schl\u00fcsseldatei", "discovery": "Suche aktivieren", + "keepalive": "Die Zeit zwischen dem Senden von Keep-Alive-Nachrichten", "password": "Passwort", "port": "Port", + "protocol": "MQTT-Protokoll", + "set_ca_cert": "Validierung des Broker-Zertifikats", + "set_client_cert": "Ein Client-Zertifikat verwenden", + "tls_insecure": "Validierung des Broker-Zertifikats ignorieren", "username": "Benutzername" }, "description": "Bitte gib die Verbindungsinformationen deines MQTT-Brokers ein." @@ -53,20 +71,40 @@ "deprecated_yaml": { "description": "Manuell konfigurierte MQTT-{platform}(en) gefunden unter Plattformschl\u00fcssel `{platform}`. \n\nBitte verschiebe die Konfiguration auf den \u201emqtt\u201c-Integrationsschl\u00fcssel und starte Home Assistant neu, um dieses Problem zu beheben. Weitere Informationen findest du in der [Dokumentation]({more_info_url}).", "title": "Deine manuell konfigurierte(n) MQTT-{platform}(en) erfordert Aufmerksamkeit" + }, + "deprecated_yaml_broker_settings": { + "description": "Die folgenden Einstellungen in \u201econfiguration.yaml\u201c wurden in den MQTT-Konfigurationseintrag migriert und \u00fcberschreiben nun die Einstellungen in \u201econfiguration.yaml\u201c:\n\u201e{deprecated_settings}\u201c \n\nBitte entferne diese Einstellungen aus \u201econfiguration.yaml\u201c und starte Home Assistant neu, um dieses Problem zu beheben. Weitere Informationen findest du in der [Dokumentation]( {more_info_url} ).", + "title": "Veraltete MQTT-Einstellungen in \u201econfiguration.yaml\u201c gefunden" } }, "options": { "error": { - "bad_birth": "Ung\u00fcltiges Birth Thema.", - "bad_will": "Ung\u00fcltiges will Thema.", - "cannot_connect": "Verbindung fehlgeschlagen" + "bad_birth": "Ung\u00fcltiges \u201eBirth\u201c-Thema", + "bad_certificate": "Das CA-Zertifikat ist ung\u00fcltig", + "bad_client_cert": "Ung\u00fcltiges Client-Zertifikat. Stelle sicher, dass eine PEM-codierte Datei bereitgestellt wird", + "bad_client_cert_key": "Client-Zertifikat und privates Zertifikat sind kein g\u00fcltiges Paar", + "bad_client_key": "Ung\u00fcltiger privater Schl\u00fcssel. Stelle sicher, dass eine PEM-codierte Datei ohne Passwort bereitgestellt wird", + "bad_discovery_prefix": "Ung\u00fcltiges Discovery-Pr\u00e4fix", + "bad_will": "Ung\u00fcltiges \u201eWill\u201c-Thema", + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_inclusion": "Das Client-Zertifikat und der private Schl\u00fcssel m\u00fcssen gemeinsam konfiguriert werden" }, "step": { "broker": { "data": { + "advanced_options": "Erweiterte Optionen", "broker": "Broker", + "certificate": "Hochladen einer benutzerdefinierten CA-Zertifikatsdatei", + "client_cert": "Client-Zertifikatsdatei hochladen", + "client_id": "Client-ID (leer lassen, um eine zuf\u00e4llig generierte zu erhalten)", + "client_key": "Private Schl\u00fcsseldatei hochladen", + "keepalive": "Die Zeit zwischen dem Senden von Keep-Alive-Nachrichten", "password": "Passwort", "port": "Port", + "protocol": "MQTT-Protokoll", + "set_ca_cert": "Validierung des Broker-Zertifikats", + "set_client_cert": "Ein Client-Zertifikat verwenden", + "tls_insecure": "Validierung des Broker-Zertifikats ignorieren", "username": "Benutzername" }, "description": "Bitte gib die Verbindungsinformationen deines MQTT-Brokers ein.", @@ -80,13 +118,14 @@ "birth_retain": "Birth Nachricht zwischenspeichern", "birth_topic": "Thema der Birth Nachricht", "discovery": "Erkennung aktivieren", + "discovery_prefix": "Discovery-Pr\u00e4fix", "will_enable": "Letzten Willen aktivieren", "will_payload": "Nutzdaten der Letzter-Wille Nachricht", "will_qos": "Letzter-Wille Nachricht QoS", "will_retain": "Letzter-Wille Nachricht zwischenspeichern", "will_topic": "Thema der Letzter-Wille Nachricht" }, - "description": "Erkennung - Wenn die Erkennung aktiviert ist (empfohlen), erkennt Home Assistant automatisch Ger\u00e4te und Entit\u00e4ten, die ihre Konfiguration auf dem MQTT-Broker ver\u00f6ffentlichen. Wenn die Erkennung deaktiviert ist, muss die gesamte Konfiguration manuell vorgenommen werden.\nGeburtsnachricht - Die Geburtsnachricht wird jedes Mal gesendet, wenn sich Home Assistant (erneut) mit dem MQTT-Broker verbindet.\nWill-Nachricht - Die Will-Nachricht wird jedes Mal gesendet, wenn Home Assistant die Verbindung zum Broker verliert, sowohl im Falle einer sauberen (z. B. Herunterfahren von Home Assistant) als auch im Falle einer unsauberen (z. B. Absturz von Home Assistant oder Verlust der Netzwerkverbindung) Verbindungstrennung.", + "description": "Erkennung - Wenn die Erkennung aktiviert ist (empfohlen), erkennt Home Assistant automatisch Ger\u00e4te und Einheiten, die ihre Konfiguration auf dem MQTT-Broker ver\u00f6ffentlichen. Wenn die Erkennung deaktiviert ist, muss die gesamte Konfiguration manuell vorgenommen werden.\nErkennungspr\u00e4fix - Das Pr\u00e4fix, mit dem ein Konfigurationsthema f\u00fcr die automatische Erkennung beginnen muss.\nBirth-Nachricht - Die Birth-Nachricht wird jedes Mal gesendet, wenn sich Home Assistant (erneut) mit dem MQTT-Broker verbindet.\nWill-Nachricht - Die Will-Nachricht wird jedes Mal gesendet, wenn Home Assistant die Verbindung zum Broker verliert, sowohl im Falle einer sauberen (z.B. Home Assistant wird heruntergefahren) als auch im Falle einer unsauberen (z.B. Home Assistant st\u00fcrzt ab oder verliert die Netzwerkverbindung) Trennung der Verbindung.", "title": "MQTT-Optionen" } } diff --git a/homeassistant/components/mqtt/translations/en.json b/homeassistant/components/mqtt/translations/en.json index f495c4eea2b07ebe576cc109f20645236113709d..f60f457abd387d541bda85284fd883badd1caa2f 100644 --- a/homeassistant/components/mqtt/translations/en.json +++ b/homeassistant/components/mqtt/translations/en.json @@ -5,15 +5,33 @@ "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { - "cannot_connect": "Failed to connect" + "bad_birth": "Invalid birth topic", + "bad_certificate": "The CA certificate is invalid", + "bad_client_cert": "Invalid client certiticate, ensure a PEM coded file is supplied", + "bad_client_cert_key": "Client certificate and private are no valid pair", + "bad_client_key": "Invalid private key, ensure a PEM coded file is supplied without password", + "bad_discovery_prefix": "Invalid discovery prefix", + "bad_will": "Invalid will topic", + "cannot_connect": "Failed to connect", + "invalid_inclusion": "The client certificate and private key must be configurered together" }, "step": { "broker": { "data": { + "advanced_options": "Advanced options", "broker": "Broker", + "certificate": "Path to custom CA certificate file", + "client_cert": "Path to a client certificate file", + "client_id": "Client ID (leave empty to randomly generated one)", + "client_key": "Path to a private key file", "discovery": "Enable discovery", + "keepalive": "The time between sending keep alive messages", "password": "Password", "port": "Port", + "protocol": "MQTT protocol", + "set_ca_cert": "Broker certificate validation", + "set_client_cert": "Use a client certificate", + "tls_insecure": "Ignore broker certificate validation", "username": "Username" }, "description": "Please enter the connection information of your MQTT broker." @@ -53,20 +71,40 @@ "deprecated_yaml": { "description": "Manually configured MQTT {platform}(s) found under platform key `{platform}`.\n\nPlease move the configuration to the `mqtt` integration key and restart Home Assistant to fix this issue. See the [documentation]({more_info_url}), for more information.", "title": "Your manually configured MQTT {platform}(s) needs attention" + }, + "deprecated_yaml_broker_settings": { + "description": "The following settings found in `configuration.yaml` were migrated to MQTT config entry and will now override the settings in `configuration.yaml`:\n`{deprecated_settings}`\n\nPlease remove these settings from `configuration.yaml` and restart Home Assistant to fix this issue. See the [documentation]({more_info_url}), for more information.", + "title": "Deprecated MQTT settings found in `configuration.yaml`" } }, "options": { "error": { - "bad_birth": "Invalid birth topic.", - "bad_will": "Invalid will topic.", - "cannot_connect": "Failed to connect" + "bad_birth": "Invalid birth topic", + "bad_certificate": "The CA certificate is invalid", + "bad_client_cert": "Invalid client certiticate, ensure a PEM coded file is supplied", + "bad_client_cert_key": "Client certificate and private are no valid pair", + "bad_client_key": "Invalid private key, ensure a PEM coded file is supplied without password", + "bad_discovery_prefix": "Invalid discovery prefix", + "bad_will": "Invalid will topic", + "cannot_connect": "Failed to connect", + "invalid_inclusion": "The client certificate and private key must be configured together" }, "step": { "broker": { "data": { + "advanced_options": "Advanced options", "broker": "Broker", + "certificate": "Upload custom CA certificate file", + "client_cert": "Upload client certificate file", + "client_id": "Client ID (leave empty to randomly generated one)", + "client_key": "Upload private key file", + "keepalive": "The time between sending keep alive messages", "password": "Password", "port": "Port", + "protocol": "MQTT protocol", + "set_ca_cert": "Broker certificate validation", + "set_client_cert": "Use a client certificate", + "tls_insecure": "Ignore broker certificate validation", "username": "Username" }, "description": "Please enter the connection information of your MQTT broker.", @@ -80,13 +118,14 @@ "birth_retain": "Birth message retain", "birth_topic": "Birth message topic", "discovery": "Enable discovery", + "discovery_prefix": "Discovery prefix", "will_enable": "Enable will message", "will_payload": "Will message payload", "will_qos": "Will message QoS", "will_retain": "Will message retain", "will_topic": "Will message topic" }, - "description": "Discovery - If discovery is enabled (recommended), Home Assistant will automatically discover devices and entities which publish their configuration on the MQTT broker. If discovery is disabled, all configuration must be done manually.\nBirth message - The birth message will be sent each time Home Assistant (re)connects to the MQTT broker.\nWill message - The will message will be sent each time Home Assistant loses its connection to the broker, both in case of a clean (e.g. Home Assistant shutting down) and in case of an unclean (e.g. Home Assistant crashing or losing its network connection) disconnect.", + "description": "Discovery - If discovery is enabled (recommended), Home Assistant will automatically discover devices and entities which publish their configuration on the MQTT broker. If discovery is disabled, all configuration must be done manually.\nDiscovery prefix - The prefix a configuration topic for automatic discovery must start with.\nBirth message - The birth message will be sent each time Home Assistant (re)connects to the MQTT broker.\nWill message - The will message will be sent each time Home Assistant loses its connection to the broker, both in case of a clean (e.g. Home Assistant shutting down) and in case of an unclean (e.g. Home Assistant crashing or losing its network connection) disconnect.", "title": "MQTT options" } } diff --git a/homeassistant/components/mqtt/translations/es.json b/homeassistant/components/mqtt/translations/es.json index e87f078c5b881072c4f0f5e764c7e23e7358f539..8c05afe997ab608513b54ee1e9bdee2e0925670e 100644 --- a/homeassistant/components/mqtt/translations/es.json +++ b/homeassistant/components/mqtt/translations/es.json @@ -5,15 +5,33 @@ "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." }, "error": { - "cannot_connect": "No se pudo conectar" + "bad_birth": "Tema de nacimiento no v\u00e1lido", + "bad_certificate": "El certificado de la CA no es v\u00e1lido", + "bad_client_cert": "Certificado de cliente no v\u00e1lido, aseg\u00farate de proporcionar un archivo codificado PEM", + "bad_client_cert_key": "Certificado de cliente y la clave privada no son un par v\u00e1lido", + "bad_client_key": "Clave privada no v\u00e1lida, aseg\u00farate de que se proporcione un archivo codificado PEM sin contrase\u00f1a", + "bad_discovery_prefix": "Prefijo de descubrimiento no v\u00e1lido", + "bad_will": "Tema de voluntad no v\u00e1lido", + "cannot_connect": "No se pudo conectar", + "invalid_inclusion": "El certificado del cliente y la clave privada deben configurarse juntos" }, "step": { "broker": { "data": { + "advanced_options": "Opciones avanzadas", "broker": "Br\u00f3ker", + "certificate": "Ruta al archivo de certificado de la CA personalizado", + "client_cert": "Ruta a un archivo de certificado de cliente", + "client_id": "ID de cliente (dejar vac\u00edo para generar uno aleatoriamente)", + "client_key": "Ruta a un archivo de clave privada", "discovery": "Habilitar descubrimiento", + "keepalive": "El tiempo entre el env\u00edo de mensajes keep alive", "password": "Contrase\u00f1a", "port": "Puerto", + "protocol": "Protocolo MQTT", + "set_ca_cert": "Validaci\u00f3n del certificado del br\u00f3ker", + "set_client_cert": "Utilizar un certificado de cliente", + "tls_insecure": "Ignorar la validaci\u00f3n del certificado del br\u00f3ker", "username": "Nombre de usuario" }, "description": "Por favor, introduce la informaci\u00f3n de conexi\u00f3n de tu br\u00f3ker MQTT." @@ -53,20 +71,40 @@ "deprecated_yaml": { "description": "Configuraci\u00f3n manual MQTT de {platform}(s) encontrada en la clave de la plataforma `{platform}`.\n\nPor favor, mueve la configuraci\u00f3n a la clave `mqtt` de la integraci\u00f3n y reinicia Home Assistant para solucionar este problema. Consulta la [documentaci\u00f3n]({more_info_url}), para obtener m\u00e1s informaci\u00f3n.", "title": "Tu configuraci\u00f3n manual MQTT de {platform}(s) necesita atenci\u00f3n" + }, + "deprecated_yaml_broker_settings": { + "description": "Los siguientes ajustes que se encuentran en `configuration.yaml` se migraron a la entrada de configuraci\u00f3n de MQTT y ahora anular\u00e1n las configuraciones en `configuration.yaml`:\n`{deprecated_settings}` \n\nElimina esta configuraci\u00f3n de `configuration.yaml` y reinicia Home Assistant para solucionar este problema. Consulta la [documentaci\u00f3n]({more_info_url}) para obtener m\u00e1s informaci\u00f3n.", + "title": "Se encontraron ajustes obsoletos de MQTT `configuration.yaml`" } }, "options": { "error": { - "bad_birth": "Tema de nacimiento no v\u00e1lido.", - "bad_will": "Tema de voluntad no v\u00e1lido.", - "cannot_connect": "No se pudo conectar" + "bad_birth": "Tema de nacimiento no v\u00e1lido", + "bad_certificate": "El certificado de la CA no es v\u00e1lido", + "bad_client_cert": "Certificado de cliente no v\u00e1lido, aseg\u00farate de proporcionar un archivo codificado PEM", + "bad_client_cert_key": "Certificado de cliente y la clave privada no son un par v\u00e1lido", + "bad_client_key": "Clave privada no v\u00e1lida, aseg\u00farate de que se proporcione un archivo codificado PEM sin contrase\u00f1a", + "bad_discovery_prefix": "Prefijo de descubrimiento no v\u00e1lido", + "bad_will": "Tema de voluntad no v\u00e1lido", + "cannot_connect": "No se pudo conectar", + "invalid_inclusion": "El certificado del cliente y la clave privada deben configurarse juntos" }, "step": { "broker": { "data": { + "advanced_options": "Opciones avanzadas", "broker": "Br\u00f3ker", + "certificate": "Subir archivo de certificado de la CA personalizado", + "client_cert": "Subir archivo de certificado de cliente", + "client_id": "ID de cliente (dejar vac\u00edo para generar uno aleatoriamente)", + "client_key": "Subir archivo de clave privada", + "keepalive": "El tiempo entre el env\u00edo de mensajes keep alive", "password": "Contrase\u00f1a", "port": "Puerto", + "protocol": "Protocolo MQTT", + "set_ca_cert": "Validaci\u00f3n del certificado del br\u00f3ker", + "set_client_cert": "Utilizar un certificado de cliente", + "tls_insecure": "Ignorar la validaci\u00f3n del certificado del br\u00f3ker", "username": "Nombre de usuario" }, "description": "Por favor, introduce la informaci\u00f3n de conexi\u00f3n de tu br\u00f3ker MQTT.", @@ -80,13 +118,14 @@ "birth_retain": "Retenci\u00f3n del mensaje de nacimiento", "birth_topic": "Tema del mensaje de nacimiento", "discovery": "Habilitar descubrimiento", + "discovery_prefix": "Prefijo de descubrimiento", "will_enable": "Habilitar mensaje de voluntad", "will_payload": "Carga del mensaje de voluntad", "will_qos": "QoS del mensaje de voluntad", "will_retain": "Retenci\u00f3n del mensaje de voluntad", "will_topic": "Tema del mensaje de voluntad" }, - "description": "Descubrimiento: si el descubrimiento est\u00e1 habilitado (recomendado), Home Assistant descubrir\u00e1 autom\u00e1ticamente los dispositivos y entidades que publican su configuraci\u00f3n en el br\u00f3ker MQTT. Si el descubrimiento est\u00e1 deshabilitado, toda la configuraci\u00f3n debe realizarse manualmente.\nMensaje de nacimiento: el mensaje de nacimiento se enviar\u00e1 cada vez que Home Assistant se (re)conecte con el br\u00f3ker MQTT.\nMensaje de voluntad: el mensaje de voluntad se enviar\u00e1 cada vez que Home Assistant pierda su conexi\u00f3n con el br\u00f3ker, tanto en caso de desconexi\u00f3n limpia (por ejemplo, que Home Assistant se apague) como en caso de desconexi\u00f3n no limpia (por ejemplo, Home Assistant se cuelgue o pierda su conexi\u00f3n de red).", + "description": "Descubrimiento: si el descubrimiento est\u00e1 habilitado (recomendado), Home Assistant descubrir\u00e1 autom\u00e1ticamente los dispositivos y entidades que publican su configuraci\u00f3n en el agente MQTT. Si el descubrimiento est\u00e1 deshabilitado, toda la configuraci\u00f3n debe realizarse manualmente.\nPrefijo de descubrimiento: el prefijo con el que debe comenzar un tema de configuraci\u00f3n para el descubrimiento autom\u00e1tico.\nMensaje de nacimiento: el mensaje de nacimiento se enviar\u00e1 cada vez que Home Assistant se (re)conecte con el br\u00f3ker MQTT.\nMensaje de voluntad: el mensaje de voluntad se enviar\u00e1 cada vez que Home Assistant pierda su conexi\u00f3n con el br\u00f3ker, tanto en caso de desconexi\u00f3n limpia (por ejemplo, que Home Assistant se apague) como no limpia (por ejemplo, Home Assistant se cuelgue o pierda su conexi\u00f3n de red).", "title": "Opciones de MQTT" } } diff --git a/homeassistant/components/mqtt/translations/et.json b/homeassistant/components/mqtt/translations/et.json index 9b85b6e51bb79bbab343cad6b4a89cf0a94205dd..373a8a64a95bd81f54aae0bc56c7a8ba15295ee1 100644 --- a/homeassistant/components/mqtt/translations/et.json +++ b/homeassistant/components/mqtt/translations/et.json @@ -5,15 +5,33 @@ "single_instance_allowed": "Lubatud on ainult \u00fcks MQTT konfiguratsioon." }, "error": { - "cannot_connect": "Vahendajaga ei saa \u00fchendust luua." + "bad_birth": "Kehtetu loomise teavitus", + "bad_certificate": "CA sertifikaat on kehtetu", + "bad_client_cert": "Kehtetu kliendi sertifikaat, veendu, et on esitatud PEM-kodeeritud fail", + "bad_client_cert_key": "Kliendisertifikaat ja privaatne sertifikaat ei ole kehtiv paar", + "bad_client_key": "Kehtetu privaatv\u00f5ti, veendu, et PEM-kodeeritud fail tarnitakse ilma paroolita", + "bad_discovery_prefix": "Sobimatu tuvastuse eesliide", + "bad_will": "Kehtetu l\u00f5petamise teavitus", + "cannot_connect": "Vahendajaga ei saa \u00fchendust luua.", + "invalid_inclusion": "Kliendisertifikaat ja privaatne v\u00f5ti tuleb konfigureerida koos." }, "step": { "broker": { "data": { + "advanced_options": "T\u00e4psemad s\u00e4tted", "broker": "Vahendaja", + "certificate": "Tee kohandatud CA-sertifikaadifaili juurde", + "client_cert": "Kliendi serdifaili tee", + "client_id": "Kliendi ID (juhuslikult genereeritud ID jaoks j\u00e4ta t\u00fchjaks)", + "client_key": "Tee privaatse v\u00f5tme faili juurde", "discovery": "Luba automaatne avastamine", + "keepalive": "Aegumiss\u00f5numite saatmise vaheline aeg", "password": "Salas\u00f5na", "port": "Port", + "protocol": "MQTT protokoll", + "set_ca_cert": "Sertifikaadi kinnitamine", + "set_client_cert": "Kasuta kliendi sertifikaati", + "tls_insecure": "Eira serdi valideerimist", "username": "Kasutajanimi" }, "description": "Sisesta oma MQTT vahendaja andmed." @@ -53,20 +71,40 @@ "deprecated_yaml": { "description": "K\u00e4sitsi seadistatud MQTT {platform} leiti platvormi v\u00f5tme ` {platform} ` alt. \n\n Selle probleemi lahendamiseks teisalda konfiguratsioon sidumisv\u00f5tmesse \"mqtt\" ja taask\u00e4ivitage Home Assistant. Lisateabe saamiseks vaata [dokumentatsiooni]( {more_info_url} ).", "title": "K\u00e4sitsi seadistatud MQTT {platform} vajab t\u00e4helepanu" + }, + "deprecated_yaml_broker_settings": { + "description": "J\u00e4rgmised failis \u201econfiguration.yaml\u201d leitud s\u00e4tted viidi \u00fcle MQTT konfiguratsioonikirjesse ja need alistavad n\u00fc\u00fcd faili \u201econfiguration.yaml\u201d s\u00e4tted:\n ` {deprecated_settings} ` \n\n Probleemi lahendamiseks eemalda need seaded saidilt \u201econfiguration.yaml\u201d ja taask\u00e4ivita koduabiline. Lisateabe saamiseks vaata [dokumentatsiooni]( {more_info_url} ).", + "title": "Configuration.yaml'is leiti vananenud MQTT seaded" } }, "options": { "error": { - "bad_birth": "Kehtetu loomise teavitus.", - "bad_will": "Kehtetu l\u00f5petamise teavitus.", - "cannot_connect": "\u00dchendamine nurjus" + "bad_birth": "Kehtetu loomise teavitus", + "bad_certificate": "CA sertifikaat on kehtetu", + "bad_client_cert": "Kehtetu kliendi sertifikaat, veendu, et on esitatud PEM-kodeeritud fail", + "bad_client_cert_key": "Kliendisertifikaat ja privaatne sertifikaat ei ole kehtiv paar", + "bad_client_key": "Kehtetu privaatv\u00f5ti, veendu, et PEM-kodeeritud fail tarnitakse ilma paroolita", + "bad_discovery_prefix": "Sobimatu tuvastuse eesliide", + "bad_will": "Kehtetu l\u00f5petamise teavitus", + "cannot_connect": "\u00dchendamine nurjus", + "invalid_inclusion": "Kliendisertifikaat ja privaatne v\u00f5ti tuleb konfigureerida koos." }, "step": { "broker": { "data": { + "advanced_options": "T\u00e4psemad s\u00e4tted", "broker": "Vahendaja", + "certificate": "Lae \u00fcles kohandatud CA-sertifikaadi fail", + "client_cert": "Lae \u00fcles kliendi sertifikaadifail", + "client_id": "Kliendi ID (juhuslikult genereeritud ID jaoks j\u00e4ta t\u00fchjaks)", + "client_key": "Privaatse v\u00f5tme faili \u00fcleslaadimine", + "keepalive": "Aegumiss\u00f5numite saatmise vaheline aeg", "password": "Salas\u00f5na", "port": "Port", + "protocol": "MQTT protokoll", + "set_ca_cert": "Sertifikaadi kinnitamine", + "set_client_cert": "Kasuta kliendi sertifikaati", + "tls_insecure": "Eira serdi valideerimist", "username": "Kasutajanimi" }, "description": "Sisesta oma MQTT vahendaja \u00fchenduse teave.", @@ -80,13 +118,14 @@ "birth_retain": "S\u00fcnniteate j\u00e4\u00e4dvustamine", "birth_topic": "S\u00fcnniteate teema", "discovery": "Luba avastamine", + "discovery_prefix": "Avastamise eesliide", "will_enable": "Luba loomisteavitus", "will_payload": "L\u00f5petamisteate v\u00e4\u00e4rtus", "will_qos": "L\u00f5petamisteate QoS", "will_retain": "L\u00f5petamisteate j\u00e4\u00e4dvustamine", "will_topic": "L\u00f5petamisteade" }, - "description": "Avastamine - kui avastamine on lubatud (soovitatav) avastab Home Assistant automaatselt seadmed ja \u00fcksused, kes avaldavad oma konfiguratsiooni MQTT maakleris. Kui avastamine on keelatud, tuleb kogu seadistamine teha k\u00e4sitsi.\n S\u00fcnnis\u00f5num - s\u00fcnnis\u00f5num saadetakse iga kord kui Home Assistant (uuesti) MQTT maakleriga \u00fchendust v\u00f5tab.\n Tahte s\u00f5num - tahte s\u00f5num saadetakse iga kord kui Home Assistant kaotab \u00fchenduse maakleriga, nii korralisel (nt Home Assistant sulgub) kui ka erakorralisel (nt Home Assistant krahhi v\u00f5i v\u00f5rgu\u00fchenduse kaotamisel) \u00fchenduse kadumisel.", + "description": "Avastamine - Kui avastamine on lubatud (soovitatav), avastab Home Assistant automaatselt seadmed ja \u00fcksused, mis avaldavad oma konfiguratsiooni MQTT-vahendaja kaudu. Kui avastamine on v\u00e4lja l\u00fclitatud, tuleb kogu konfigureerimine teha k\u00e4sitsi.\nDiscovery prefix (Avastamise eesliide) - automaatse avastamise konfiguratsiooniteema eesliide, millega peab algama.\nBirth message (s\u00fcnniteade) - s\u00fcnniteade saadetakse iga kord, kui Home Assistant (taas)\u00fchendub MQTT-vahendajaga.\nWill message - Will message saadetakse iga kord, kui Home Assistant kaotab \u00fchenduse maakleriga, nii puhta (nt Home Assistant l\u00f5petab tegevuse) kui ka ebapuhta (nt Home Assistant kukub kokku v\u00f5i kaotab v\u00f5rgu\u00fchenduse) katkestamise korral.", "title": "MQTT valikud" } } diff --git a/homeassistant/components/mqtt/translations/fr.json b/homeassistant/components/mqtt/translations/fr.json index 87ce8c2bdeeb2622b3d338d1a13d9e1d8fe48c7f..49d041bc0f8328102c58d24cf614add657ac1249 100644 --- a/homeassistant/components/mqtt/translations/fr.json +++ b/homeassistant/components/mqtt/translations/fr.json @@ -5,15 +5,19 @@ "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, "error": { + "bad_discovery_prefix": "Pr\u00e9fixe de d\u00e9couverte non valide", "cannot_connect": "\u00c9chec de connexion" }, "step": { "broker": { "data": { + "advanced_options": "Options avanc\u00e9es", "broker": "Broker", "discovery": "Activer la d\u00e9couverte", "password": "Mot de passe", "port": "Port", + "protocol": "Protocole MQTT", + "set_client_cert": "Utiliser un certificat client", "username": "Nom d'utilisateur" }, "description": "Veuillez entrer les informations de connexion de votre broker MQTT." @@ -57,15 +61,19 @@ "options": { "error": { "bad_birth": "Sujet de la naissance non valide.", + "bad_discovery_prefix": "Pr\u00e9fixe de d\u00e9couverte non valide", "bad_will": "Sujet du testament non valide.", "cannot_connect": "\u00c9chec de connexion" }, "step": { "broker": { "data": { + "advanced_options": "Options avanc\u00e9es", "broker": "Broker", "password": "Mot de passe", "port": "Port", + "protocol": "Protocole MQTT", + "set_client_cert": "Utiliser un certificat client", "username": "Nom d'utilisateur" }, "description": "Veuillez entrer les informations de connexion de votre broker MQTT.", @@ -79,6 +87,7 @@ "birth_retain": "Retenir le message de naissance", "birth_topic": "Topic du message de naissance", "discovery": "Activer la d\u00e9couverte", + "discovery_prefix": "Pr\u00e9fixe de d\u00e9couverte", "will_enable": "Activer le message de naissance", "will_payload": "Contenu du message de testament", "will_qos": "QoS du message de testament", diff --git a/homeassistant/components/mqtt/translations/hu.json b/homeassistant/components/mqtt/translations/hu.json index 805b8106fbab1c58824a040ee2d2530327452885..c643f0ef40458ac781ed3e1b9225c5a2596258d6 100644 --- a/homeassistant/components/mqtt/translations/hu.json +++ b/homeassistant/components/mqtt/translations/hu.json @@ -5,15 +5,33 @@ "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "bad_birth": "\u00c9rv\u00e9nytelen 'birth' topik", + "bad_certificate": "A CA-tan\u00fas\u00edtv\u00e1ny \u00e9rv\u00e9nytelen", + "bad_client_cert": "\u00c9rv\u00e9nytelen \u00fcgyf\u00e9ltan\u00fas\u00edtv\u00e1ny. Gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy PEM k\u00f3dolt f\u00e1jl van megadva", + "bad_client_cert_key": "Az \u00fcgyf\u00e9ltan\u00fas\u00edtv\u00e1ny \u00e9s a priv\u00e1t tan\u00fas\u00edtv\u00e1ny nem \u00e9rv\u00e9nyes p\u00e1r", + "bad_client_key": "\u00c9rv\u00e9nytelen priv\u00e1t kulcs, gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy PEM k\u00f3dol\u00e1s\u00fa f\u00e1jlt k\u00fcld\u00f6tt jelsz\u00f3 n\u00e9lk\u00fcl", + "bad_discovery_prefix": "\u00c9rv\u00e9nytelen felfedez\u00e9si el\u0151tag", + "bad_will": "\u00c9rv\u00e9nytelen 'will' topik", + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_inclusion": "Az \u00fcgyf\u00e9ltan\u00fas\u00edtv\u00e1nyt \u00e9s a mag\u00e1nkulcsot egy\u00fctt kell konfigur\u00e1lni" }, "step": { "broker": { "data": { + "advanced_options": "Speci\u00e1lis be\u00e1ll\u00edt\u00e1sok", "broker": "Br\u00f3ker", + "certificate": "Az egy\u00e9ni CA-tan\u00fas\u00edtv\u00e1nyf\u00e1jl el\u00e9r\u00e9si \u00fatja", + "client_cert": "Az \u00fcgyf\u00e9ltan\u00fas\u00edtv\u00e1ny f\u00e1jl el\u00e9r\u00e9si \u00fatja", + "client_id": "\u00dcgyf\u00e9l azonos\u00edt\u00f3 (hagyja \u00fcresen a v\u00e9letlenszer\u0171en gener\u00e1lt azonos\u00edt\u00f3hoz)", + "client_key": "A priv\u00e1t kulcsf\u00e1jl el\u00e9r\u00e9si \u00fatvonala", "discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se", + "keepalive": "A keep alive \u00fczenetek k\u00fcld\u00e9se k\u00f6z\u00f6tti id\u0151", "password": "Jelsz\u00f3", "port": "Port", + "protocol": "MQTT protokoll", + "set_ca_cert": "Br\u00f3kertan\u00fas\u00edtv\u00e1ny \u00e9rv\u00e9nyes\u00edt\u00e9s", + "set_client_cert": "\u00dcgyf\u00e9ltan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", + "tls_insecure": "A br\u00f3kertan\u00fas\u00edtv\u00e1ny \u00e9rv\u00e9nyes\u00edt\u00e9s\u00e9nek figyelmen k\u00edv\u00fcl hagy\u00e1sa", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, "description": "K\u00e9rem, adja meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait." @@ -53,20 +71,40 @@ "deprecated_yaml": { "description": "A manu\u00e1lisan konfigur\u00e1lt MQTT {platform} a `{platform}` platformkulcs alatt tal\u00e1lhat\u00f3. \n\n A probl\u00e9ma megold\u00e1s\u00e1hoz helyezze \u00e1t a konfigur\u00e1ci\u00f3t az \"mqtt\" integr\u00e1ci\u00f3s kulcsra, \u00e9s ind\u00edtsa \u00fajra a Home Assistant alkalmaz\u00e1st. Tov\u00e1bbi inform\u00e1ci\u00f3\u00e9rt tekintse meg a [dokument\u00e1ci\u00f3t]({more_info_url}).", "title": "A manu\u00e1lisan konfigur\u00e1lt MQTT {platform} figyelmet ig\u00e9nyel" + }, + "deprecated_yaml_broker_settings": { + "description": "A k\u00f6vetkez\u0151 be\u00e1ll\u00edt\u00e1sok a `configuration.yaml'-ben tal\u00e1lhat\u00f3ak, \u00e1tker\u00fcltek az MQTT config bejegyz\u00e9sbe, \u00e9s mostant\u00f3l fel\u00fcl\u00edrj\u00e1k a `configuration.yaml'-ben tal\u00e1lhat\u00f3 be\u00e1ll\u00edt\u00e1sokat:\n`{deprecated_settings}`\n\nK\u00e9rj\u00fck, t\u00e1vol\u00edtsa el ezeket a be\u00e1ll\u00edt\u00e1sokat a `configuration.yaml` f\u00e1jlb\u00f3l \u00e9s ind\u00edtsa \u00fajra a Home Assistant-ot a probl\u00e9ma megold\u00e1s\u00e1hoz. Tov\u00e1bbi inform\u00e1ci\u00f3 a [document\u00e1ci\u00f3ban]({more_info_url}).", + "title": "Elavult MQTT-be\u00e1ll\u00edt\u00e1sok tal\u00e1lhat\u00f3k a \"configuration.yaml\" f\u00e1jlban" } }, "options": { "error": { "bad_birth": "\u00c9rv\u00e9nytelen 'birth' topik.", + "bad_certificate": "A CA-tan\u00fas\u00edtv\u00e1ny \u00e9rv\u00e9nytelen", + "bad_client_cert": "\u00c9rv\u00e9nytelen \u00fcgyf\u00e9ltan\u00fas\u00edtv\u00e1ny. Gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy PEM k\u00f3dolt f\u00e1jl van megadva", + "bad_client_cert_key": "Az \u00fcgyf\u00e9ltan\u00fas\u00edtv\u00e1ny \u00e9s a priv\u00e1t tan\u00fas\u00edtv\u00e1ny nem \u00e9rv\u00e9nyes p\u00e1r", + "bad_client_key": "\u00c9rv\u00e9nytelen priv\u00e1t kulcs, gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy PEM k\u00f3dol\u00e1s\u00fa f\u00e1jlt k\u00fcld\u00f6tt jelsz\u00f3 n\u00e9lk\u00fcl", + "bad_discovery_prefix": "\u00c9rv\u00e9nytelen felfedez\u00e9si el\u0151tag", "bad_will": "\u00c9rv\u00e9nytelen 'will' topik.", - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_inclusion": "Az \u00fcgyf\u00e9ltan\u00fas\u00edtv\u00e1nyt \u00e9s a priv\u00e1t kulcsot egy\u00fctt kell konfigur\u00e1lni" }, "step": { "broker": { "data": { + "advanced_options": "Speci\u00e1lis be\u00e1ll\u00edt\u00e1sok", "broker": "Br\u00f3ker", + "certificate": "Egy\u00e9ni CA-tan\u00fas\u00edtv\u00e1nyf\u00e1jl felt\u00f6lt\u00e9se", + "client_cert": "\u00dcgyf\u00e9ltan\u00fas\u00edtv\u00e1ny f\u00e1jl felt\u00f6lt\u00e9se", + "client_id": "\u00dcgyf\u00e9l azonos\u00edt\u00f3 (hagyja \u00fcresen a v\u00e9letlenszer\u0171en gener\u00e1lt azonos\u00edt\u00f3hoz)", + "client_key": "Priv\u00e1t kulcsf\u00e1jl felt\u00f6lt\u00e9se", + "keepalive": "A keep alive \u00fczenetek k\u00fcld\u00e9se k\u00f6z\u00f6tti id\u0151", "password": "Jelsz\u00f3", "port": "Port", + "protocol": "MQTT protokoll", + "set_ca_cert": "Br\u00f3kertan\u00fas\u00edtv\u00e1ny \u00e9rv\u00e9nyes\u00edt\u00e9s", + "set_client_cert": "\u00dcgyf\u00e9ltan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", + "tls_insecure": "A br\u00f3kertan\u00fas\u00edtv\u00e1ny \u00e9rv\u00e9nyes\u00edt\u00e9s\u00e9nek figyelmen k\u00edv\u00fcl hagy\u00e1sa", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, "description": "K\u00e9rem, adja meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait.", @@ -80,13 +118,14 @@ "birth_retain": "A sz\u00fclet\u00e9si \u00fczenet meg\u0151rz\u00e9se", "birth_topic": "Sz\u00fclet\u00e9si \u00fczenet t\u00e9m\u00e1ja", "discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se", + "discovery_prefix": "Felfedez\u00e9s el\u0151tag", "will_enable": "Enged\u00e9lyez\u00e9si \u00fczenet", "will_payload": "\u00dczenet", "will_qos": "QoS \u00fczenet", "will_retain": "\u00dczenet megtart\u00e1sa", "will_topic": "\u00dczenet t\u00e9m\u00e1ja" }, - "description": "Discovery - Ha a felfedez\u00e9s enged\u00e9lyezve van (aj\u00e1nlott), akkor Home Assistant automatikusan felfedezi azokat az eszk\u00f6z\u00f6ket \u00e9s entit\u00e1sokat, amelyek k\u00f6zz\u00e9teszik konfigur\u00e1ci\u00f3jukat az MQTT br\u00f3keren. Ha a felfedez\u00e9s le van tiltva, minden konfigur\u00e1ci\u00f3t manu\u00e1lisan kell elv\u00e9gezni.\nBirth \u00fczenet - A sz\u00fclet\u00e9si \u00fczenet minden alkalommal el lesz k\u00fcldve, amikor Home Assistant (\u00fajra) csatlakozik az MQTT br\u00f3kerhez.\nWill \u00fczenet - Az akarat\u00fczenet minden alkalommal el lesz k\u00fcldve, amikor Home Assistant elvesz\u00edti a kapcsolatot a k\u00f6zvet\u00edt\u0151vel, mind takar\u00edt\u00e1s eset\u00e9n (pl. Home Assistant le\u00e1ll\u00edt\u00e1sa), mind rendelenes helyzetben (pl. Home Assistant \u00f6sszeomlik vagy megszakad a h\u00e1l\u00f3zati kapcsolata).", + "description": "Discovery - Ha a felfedez\u00e9s enged\u00e9lyezve van (aj\u00e1nlott), akkor Home Assistant automatikusan felfedezi azokat az eszk\u00f6z\u00f6ket \u00e9s entit\u00e1sokat, amelyek k\u00f6zz\u00e9teszik konfigur\u00e1ci\u00f3jukat az MQTT br\u00f3keren. Ha a felfedez\u00e9s le van tiltva, minden konfigur\u00e1ci\u00f3t manu\u00e1lisan kell elv\u00e9gezni.\nBirth \u00fczenet - A sz\u00fclet\u00e9si \u00fczenet minden alkalommal el lesz k\u00fcldve, amikor Home Assistant (\u00fajra) csatlakozik az MQTT br\u00f3kerhez.\nWill \u00fczenet - Az akarat\u00fczenet minden alkalommal el lesz k\u00fcldve, amikor Home Assistant elvesz\u00edti a kapcsolatot a k\u00f6zvet\u00edt\u0151vel, mind tiszta esetben (pl. Home Assistant le\u00e1ll\u00edt\u00e1sa), mind rendelenes helyzetben (pl. Home Assistant lefagy vagy megszakad a h\u00e1l\u00f3zati kapcsolata).", "title": "MQTT opci\u00f3k" } } diff --git a/homeassistant/components/mqtt/translations/id.json b/homeassistant/components/mqtt/translations/id.json index 8a45f155815ef5a4ef105d29bc0ba47d74e25db0..4cb0fda8a9108ef3c521b5b1dc110fcbc69ff6ec 100644 --- a/homeassistant/components/mqtt/translations/id.json +++ b/homeassistant/components/mqtt/translations/id.json @@ -51,7 +51,7 @@ }, "issues": { "deprecated_yaml": { - "description": "MQTT {platform}(s) yang dikonfigurasi secara manual ditemukan di bawah kunci platform `{platform}`.\n\nPindahkan konfigurasi ke kunci integrasi `mqtt` dan mulai ulang Home Assistant untuk memperbaiki masalah ini. Lihat [dokumentasi]({more_info_url}), untuk informasi lebih lanjut.", + "description": "MQTT {platform} yang dikonfigurasi secara manual ditemukan di bawah kunci platform `{platform}`.\n\nPindahkan konfigurasi ke kunci integrasi `mqtt` dan mulai ulang Home Assistant untuk memperbaiki masalah ini. Lihat [dokumentasi]({more_info_url}), untuk informasi lebih lanjut.", "title": "Entitas MQTT {platform} yang dikonfigurasi secara manual membutuhkan perhatian" } }, diff --git a/homeassistant/components/mqtt/translations/it.json b/homeassistant/components/mqtt/translations/it.json index a94dd83a55d5e65f4786c2938c6497d080d133c0..9b94a95bdc1d3b3a088962070cac66d8be6b8159 100644 --- a/homeassistant/components/mqtt/translations/it.json +++ b/homeassistant/components/mqtt/translations/it.json @@ -5,15 +5,32 @@ "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, "error": { - "cannot_connect": "Impossibile connettersi" + "bad_birth": "Argomento di nascita non valido", + "bad_certificate": "Il certificato CA non \u00e8 valido", + "bad_client_cert": "Certificato client non valido, assicurarsi che venga fornito un file codificato PEM", + "bad_client_cert_key": "Il certificato del client e il privato non sono accoppiati in modo valido", + "bad_client_key": "Chiave privata non valida, assicurarsi che venga fornito un file codificato PEM senza password", + "bad_discovery_prefix": "Prefisso di ricerca non valido", + "cannot_connect": "Impossibile connettersi", + "invalid_inclusion": "Il certificato del client e la chiave privata devono essere configurati insieme" }, "step": { "broker": { "data": { + "advanced_options": "Opzioni avanzate", "broker": "Broker", + "certificate": "Percorso del file del certificato CA personalizzato", + "client_cert": "Percorso per un file di certificato cliente", + "client_id": "ID cliente (lasciare vuoto per generarne uno in modo casuale)", + "client_key": "Percorso per un file della chiave privata", "discovery": "Attiva il rilevamento", + "keepalive": "L'intervallo di tempo tra l'invio di messaggi di mantenimento attivo", "password": "Password", "port": "Porta", + "protocol": "Protocollo MQTT", + "set_ca_cert": "Convalida del certificato del broker", + "set_client_cert": "Utilizzare un certificato client", + "tls_insecure": "Ignorare la convalida del certificato del broker", "username": "Nome utente" }, "description": "Inserisci le informazioni di connessione del tuo broker MQTT." @@ -58,15 +75,29 @@ "options": { "error": { "bad_birth": "Argomento birth non valido.", + "bad_certificate": "Certificato CA non valido", + "bad_client_cert_key": "Il certificato del client e il certificato privato non sono accoppiati in modo valido", + "bad_client_key": "Chiave privata non valida, assicurarsi che venga fornito un file codificato PEM senza password", + "bad_discovery_prefix": "Prefisso di ricerca non valido", "bad_will": "Argomento will non valido.", - "cannot_connect": "Impossibile connettersi" + "cannot_connect": "Impossibile connettersi", + "invalid_inclusion": "Il certificato e la chiave privata del client devono essere configurati insieme" }, "step": { "broker": { "data": { + "advanced_options": "Opzioni avanzate", "broker": "Broker", + "certificate": "Carica il file del certificato CA personalizzato", + "client_cert": "Carica il file del certificato cliente", + "client_id": "ID cliente (lasciare vuoto per generarne uno in modo casuale)", + "client_key": "Carica il file della chiave privata", "password": "Password", "port": "Porta", + "protocol": "Protocollo MQTT", + "set_ca_cert": "Convalida del certificato del broker", + "set_client_cert": "Utilizza un certificato client", + "tls_insecure": "Ignora la convalida del certificato del broker", "username": "Nome utente" }, "description": "Inserisci le informazioni di connessione del tuo broker MQTT.", @@ -80,6 +111,7 @@ "birth_retain": "Persistenza del messaggio birth", "birth_topic": "Argomento del messaggio birth", "discovery": "Attiva il rilevamento", + "discovery_prefix": "Scopri il prefisso", "will_enable": "Abilita il messaggio testamento", "will_payload": "Payload del messaggio testamento", "will_qos": "QoS del messaggio testamento", diff --git a/homeassistant/components/mqtt/translations/no.json b/homeassistant/components/mqtt/translations/no.json index aa1633f6b27157bb0ab78d61d207d8cb3623eb1c..910f47ac02a5280cda4048b72d21993f18996960 100644 --- a/homeassistant/components/mqtt/translations/no.json +++ b/homeassistant/components/mqtt/translations/no.json @@ -5,15 +5,33 @@ "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "error": { - "cannot_connect": "Tilkobling mislyktes" + "bad_birth": "Ugyldig f\u00f8dselsemne", + "bad_certificate": "CA-sertifikatet er ugyldig", + "bad_client_cert": "Ugyldig klientsertifikat, s\u00f8rg for at en PEM-kodet fil leveres", + "bad_client_cert_key": "Klientsertifikat og privat er ikke noe gyldig par", + "bad_client_key": "Ugyldig privat n\u00f8kkel, s\u00f8rg for at en PEM-kodet fil leveres uten passord", + "bad_discovery_prefix": "Ugyldig oppdagelsesprefiks", + "bad_will": "Ugyldig viljeemne", + "cannot_connect": "Tilkobling mislyktes", + "invalid_inclusion": "Klientsertifikatet og den private n\u00f8kkelen m\u00e5 konfigureres sammen" }, "step": { "broker": { "data": { + "advanced_options": "Avanserte instillinger", "broker": "Megler", + "certificate": "Bane til egendefinert CA-sertifikatfil", + "client_cert": "Bane til en klientsertifikatfil", + "client_id": "Klient-ID (la st\u00e5 tomt til tilfeldig generert)", + "client_key": "Bane til en privat n\u00f8kkelfil", "discovery": "Aktiver oppdagelse", + "keepalive": "Tiden mellom sending hold levende meldinger", "password": "Passord", "port": "Port", + "protocol": "MQTT-protokoll", + "set_ca_cert": "Validering av meglersertifikat", + "set_client_cert": "Bruk et klientsertifikat", + "tls_insecure": "Ignorer validering av meglersertifikat", "username": "Brukernavn" }, "description": "Vennligst fyll ut tilkoblingsinformasjonen for din MQTT megler." @@ -53,20 +71,40 @@ "deprecated_yaml": { "description": "Manuelt konfigurert MQTT {platform} (er) funnet under plattformn\u00f8kkelen ` {platform} `. \n\n Flytt konfigurasjonen til `mqtt`-integrasjonsn\u00f8kkelen og start Home Assistant p\u00e5 nytt for \u00e5 fikse dette problemet. Se [dokumentasjonen]( {more_info_url} ), for mer informasjon.", "title": "Din manuelt konfigurerte MQTT {platform} (er) trenger oppmerksomhet" + }, + "deprecated_yaml_broker_settings": { + "description": "F\u00f8lgende innstillinger funnet i 'configuration.yaml' ble migrert til MQTT-konfigurasjonsoppf\u00f8ring og vil n\u00e5 overstyre innstillingene i 'configuration.yaml':\n'{deprecated_settings}'\n\nFjern disse innstillingene fra configuration.yaml, og start Home Assistant p\u00e5 nytt for \u00e5 l\u00f8se dette problemet. Se [dokumentasjon]({more_info_url}), for mer informasjon.", + "title": "Utdaterte MQTT-innstillinger funnet i `configuration.yaml`" } }, "options": { "error": { - "bad_birth": "Ugyldig f\u00f8dselsemne.", - "bad_will": "Ugyldig emne.", - "cannot_connect": "Tilkobling mislyktes" + "bad_birth": "Ugyldig f\u00f8dselsemne", + "bad_certificate": "CA-sertifikatet er ugyldig", + "bad_client_cert": "Ugyldig klientsertifikat, s\u00f8rg for at en PEM-kodet fil leveres", + "bad_client_cert_key": "Klientsertifikat og privat er ikke noe gyldig par", + "bad_client_key": "Ugyldig privat n\u00f8kkel, s\u00f8rg for at en PEM-kodet fil leveres uten passord", + "bad_discovery_prefix": "Ugyldig oppdagelsesprefiks", + "bad_will": "Ugyldig viljeemne", + "cannot_connect": "Tilkobling mislyktes", + "invalid_inclusion": "Klientsertifikatet og den private n\u00f8kkelen m\u00e5 konfigureres sammen" }, "step": { "broker": { "data": { + "advanced_options": "Avanserte instillinger", "broker": "Megler", + "certificate": "Last opp egendefinert CA-sertifikatfil", + "client_cert": "Last opp klientsertifikatfil", + "client_id": "Klient-ID (la st\u00e5 tomt til tilfeldig generert)", + "client_key": "Last opp privat n\u00f8kkelfil", + "keepalive": "Tiden mellom sending hold levende meldinger", "password": "Passord", "port": "Port", + "protocol": "MQTT-protokoll", + "set_ca_cert": "Validering av meglersertifikat", + "set_client_cert": "Bruk et klientsertifikat", + "tls_insecure": "Ignorer validering av meglersertifikat", "username": "Brukernavn" }, "description": "Vennligst oppgi tilkoblingsinformasjonen for din MQTT megler.", @@ -80,13 +118,14 @@ "birth_retain": "F\u00f8dselsmelding behold", "birth_topic": "F\u00f8dselsmelding emne", "discovery": "Aktiver oppdagelse", + "discovery_prefix": "Oppdagelsesprefiks", "will_enable": "Aktiver will melding", "will_payload": "Testament melding nyttelast", "will_qos": "Testament melding QoS", "will_retain": "Testament melding behold", "will_topic": "Testament melding emne" }, - "description": "Oppdagelse - Hvis oppdagelse er aktivert (anbefales), vil Home Assistant automatisk oppdage enheter og entiteter som publiserer konfigurasjonen til MQTT-megleren. Hvis oppdaglse er deaktivert, m\u00e5 all konfigurasjon utf\u00f8res manuelt.\nF\u00f8dselsmelding - F\u00f8dselsmeldingen vil bli sendt hver gang Home Assistant kobler til MQTT megleren.\nWill message - Will-meldingen vil bli sendt hver gang Home Assistant mister forbindelsen til megleren, b\u00e5de i tilfelle en ren (f.eks. at Home Assistant avsluttes) og i tilfelle en uren (f.eks. Home Assistant krasjer eller mister nettverkstilkoblingen) frakobling.", + "description": "Oppdagelse - Hvis oppdagelse er aktivert (anbefalt), vil Home Assistant automatisk oppdage enheter og enheter som publiserer konfigurasjonen deres p\u00e5 MQTT-megleren. Hvis oppdagelse er deaktivert, m\u00e5 all konfigurasjon gj\u00f8res manuelt.\n Oppdagelsesprefiks - Prefikset et konfigurasjonsemne for automatisk oppdagelse m\u00e5 starte med.\n F\u00f8dselsmelding - F\u00f8dselsmeldingen vil bli sendt hver gang Home Assistant (gjen)kobler til MQTT-megleren.\n Viljemelding - Viljemeldingen vil bli sendt hver gang Home Assistant mister forbindelsen til megleren, b\u00e5de i tilfelle en rengj\u00f8ring (f.eks. Home Assistant sl\u00e5r av) og i tilfelle en urent (f.eks. Home Assistant krasjer eller mister nettverksforbindelsen) koble fra.", "title": "MQTT-alternativer" } } diff --git a/homeassistant/components/mqtt/translations/pl.json b/homeassistant/components/mqtt/translations/pl.json index dad798296a1c4bb984a0ffc384eb9686dc912d5b..0084c0a1b12c80f868f1b1a5a54fdd33c32e0652 100644 --- a/homeassistant/components/mqtt/translations/pl.json +++ b/homeassistant/components/mqtt/translations/pl.json @@ -5,15 +5,33 @@ "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." }, "error": { - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + "bad_birth": "Nieprawid\u0142owy temat \"birth\"", + "bad_certificate": "Certyfikat CA jest nieprawid\u0142owy", + "bad_client_cert": "Nieprawid\u0142owy certyfikat klienta, upewnij si\u0119, \u017ce dostarczono plik zakodowany w formacie PEM", + "bad_client_cert_key": "Certyfikat klienta i prywatny klucz nie s\u0105 prawid\u0142ow\u0105 par\u0105", + "bad_client_key": "Nieprawid\u0142owy klucz prywatny, upewnij si\u0119, \u017ce zakodowany plik PEM jest dostarczony bez has\u0142a", + "bad_discovery_prefix": "Nieprawid\u0142owy prefiks wykrywania", + "bad_will": "Nieprawid\u0142owy temat \"will\"", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_inclusion": "Certyfikat klienta i klucz prywatny musz\u0105 by\u0107 skonfigurowane razem" }, "step": { "broker": { "data": { + "advanced_options": "Opcje zaawansowane", "broker": "Po\u015brednik", + "certificate": "\u015acie\u017cka do pliku z niestandardowym certyfikatem CA", + "client_cert": "\u015acie\u017cka do pliku certyfikatu klienta", + "client_id": "Identyfikator klienta (pozostaw puste, aby wygenerowa\u0107 losowo)", + "client_key": "\u015acie\u017cka do pliku klucza prywatnego", "discovery": "W\u0142\u0105cz wykrywanie", + "keepalive": "Czas pomi\u0119dzy wys\u0142aniem wiadomo\u015bci \"keep alive\"", "password": "Has\u0142o", "port": "Port", + "protocol": "Protok\u00f3\u0142 MQTT", + "set_ca_cert": "Sprawdzanie certyfikatu brokera", + "set_client_cert": "U\u017cyj certyfikatu klienta", + "tls_insecure": "Ignoruj sprawdzanie certyfikatu brokera", "username": "Nazwa u\u017cytkownika" }, "description": "Wprowad\u017a informacje o po\u0142\u0105czeniu po\u015brednika MQTT." @@ -53,20 +71,40 @@ "deprecated_yaml": { "description": "Znaleziono r\u0119cznie skonfigurowane platformy MQTT z kluczem `{platform}`.\n\nAby rozwi\u0105za\u0107 ten problem, przenie\u015b konfiguracj\u0119 do klucza integracji `mqtt` i uruchom ponownie Home Assistant. Zobacz [dokumentacj\u0119]( {more_info_url} ), aby uzyska\u0107 wi\u0119cej informacji.", "title": "Twoja r\u0119cznie skonfigurowana platforma MQTT {platform} wymaga uwagi" + }, + "deprecated_yaml_broker_settings": { + "description": "Nast\u0119puj\u0105ce ustawienia znalezione w `configuration.yaml` zosta\u0142y przeniesione do wpisu konfiguracyjnego MQTT i zast\u0105pi\u0105 teraz ustawienia z `configuration.yaml`:\n`{deprecated_settings}` \n\nUsu\u0144 te ustawienia z pliku `configuration.yaml` i uruchom ponownie Home Assistant, aby rozwi\u0105za\u0107 ten problem. Zobacz [dokumentacj\u0119]({more_info_url}), aby uzyska\u0107 wi\u0119cej informacji.", + "title": "Znaleziono przestarza\u0142e ustawienia MQTT w pliku `configuration.yaml`" } }, "options": { "error": { "bad_birth": "Nieprawid\u0142owy temat \"birth\"", + "bad_certificate": "Certyfikat CA jest nieprawid\u0142owy", + "bad_client_cert": "Nieprawid\u0142owy certyfikat klienta, upewnij si\u0119, \u017ce dostarczono plik zakodowany w formacie PEM", + "bad_client_cert_key": "Certyfikat klienta i prywatny klucz nie s\u0105 prawid\u0142ow\u0105 par\u0105", + "bad_client_key": "Nieprawid\u0142owy klucz prywatny, upewnij si\u0119, \u017ce zakodowany plik PEM jest dostarczony bez has\u0142a", + "bad_discovery_prefix": "Nieprawid\u0142owy prefiks wykrywania", "bad_will": "Nieprawid\u0142owy temat \"will\"", - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_inclusion": "Certyfikat klienta i klucz prywatny musz\u0105 by\u0107 skonfigurowane razem" }, "step": { "broker": { "data": { + "advanced_options": "Opcje zaawansowane", "broker": "Po\u015brednik", + "certificate": "Prze\u015blij plik z niestandardowym certyfikatem CA", + "client_cert": "Prze\u015blij plik certyfikatu klienta", + "client_id": "Identyfikator klienta (pozostaw puste, aby wygenerowa\u0107 losowo)", + "client_key": "Prze\u015blij plik klucza prywatnego", + "keepalive": "Czas pomi\u0119dzy wys\u0142aniem wiadomo\u015bci \"keep alive\"", "password": "Has\u0142o", "port": "Port", + "protocol": "Protok\u00f3\u0142 MQTT", + "set_ca_cert": "Sprawdzanie certyfikatu brokera", + "set_client_cert": "U\u017cyj certyfikatu klienta", + "tls_insecure": "Ignoruj sprawdzanie certyfikatu brokera", "username": "Nazwa u\u017cytkownika" }, "description": "Wprowad\u017a informacje o po\u0142\u0105czeniu po\u015brednika MQTT", @@ -80,13 +118,14 @@ "birth_retain": "Flaga \"retain\" wiadomo\u015bci \"birth\"", "birth_topic": "Temat wiadomo\u015bci \"birth\"", "discovery": "W\u0142\u0105cz wykrywanie", + "discovery_prefix": "Prefiks wykrywania", "will_enable": "W\u0142\u0105cz wiadomo\u015b\u0107 \"will\"", "will_payload": "Warto\u015b\u0107 wiadomo\u015bci \"will\"", "will_qos": "QoS wiadomo\u015bci \"will\"", "will_retain": "Flaga \"retain\" wiadomo\u015bci \"will\"", "will_topic": "Temat wiadomo\u015bci \"will\"" }, - "description": "Wykrywanie - je\u015bli wykrywanie jest w\u0142\u0105czone (zalecane), Home Assistant automatycznie wykryje urz\u0105dzenia i encje, kt\u00f3re publikuj\u0105 swoj\u0105 konfiguracj\u0119 w brokerze MQTT. Je\u015bli wykrywanie jest wy\u0142\u0105czone, ca\u0142\u0105 konfiguracj\u0119 nale\u017cy wykona\u0107 r\u0119cznie.\nWiadomo\u015b\u0107 Birth - wiadomo\u015b\u0107 Birth zostanie wys\u0142ana za ka\u017cdym razem, gdy Home Assistant (ponownie) po\u0142\u0105czy si\u0119 z brokerem MQTT.\nWiadomo\u015b\u0107 Will - wiadomo\u015b\u0107 Will b\u0119dzie wysy\u0142ana za ka\u017cdym razem, gdy Home Assistant utraci po\u0142\u0105czenie z brokerem, zar\u00f3wno w przypadku czystego (np. wy\u0142\u0105czenie Home Assistanta), jak i w przypadku nieczystego (np. zawieszenie Home Assistanta lub utrata po\u0142\u0105czenia sieciowego) roz\u0142\u0105czenia.", + "description": "Wykrywanie - je\u015bli wykrywanie jest w\u0142\u0105czone (zalecane), Home Assistant automatycznie wykryje urz\u0105dzenia i encje, kt\u00f3re publikuj\u0105 swoj\u0105 konfiguracj\u0119 w brokerze MQTT. Je\u015bli wykrywanie jest wy\u0142\u0105czone, ca\u0142\u0105 konfiguracj\u0119 nale\u017cy wykona\u0107 r\u0119cznie.\nPrefiks wykrywania - prefiks od kt\u00f3rego zaczyna\u0107 ma si\u0119 temat automatycznego wykrywania.\nWiadomo\u015b\u0107 Birth - wiadomo\u015b\u0107 Birth zostanie wys\u0142ana za ka\u017cdym razem, gdy Home Assistant (ponownie) po\u0142\u0105czy si\u0119 z brokerem MQTT.\nWiadomo\u015b\u0107 Will - wiadomo\u015b\u0107 Will b\u0119dzie wysy\u0142ana za ka\u017cdym razem, gdy Home Assistant utraci po\u0142\u0105czenie z brokerem, zar\u00f3wno w przypadku czystego (np. wy\u0142\u0105czenie Home Assistanta), jak i w przypadku nieczystego roz\u0142\u0105czenia (np. zawieszenie Home Assistanta lub utrata po\u0142\u0105czenia sieciowego).", "title": "Opcje MQTT" } } diff --git a/homeassistant/components/mqtt/translations/pt-BR.json b/homeassistant/components/mqtt/translations/pt-BR.json index 518606852702dfcbd7762b9b651ecdd27894e920..d9e8ac431928542de34635e019e2f0c033fa43f1 100644 --- a/homeassistant/components/mqtt/translations/pt-BR.json +++ b/homeassistant/components/mqtt/translations/pt-BR.json @@ -5,15 +5,33 @@ "single_instance_allowed": "J\u00e1 configurado. Apenas uma configura\u00e7\u00e3o \u00e9 poss\u00edvel." }, "error": { - "cannot_connect": "Falha ao conectar" + "bad_birth": "T\u00f3pico de nascimento inv\u00e1lido", + "bad_certificate": "O certificado CA \u00e9 inv\u00e1lido", + "bad_client_cert": "Certificado de cliente inv\u00e1lido, certifique-se de que um arquivo codificado PEM seja fornecido", + "bad_client_cert_key": "Certificado de cliente e privado n\u00e3o s\u00e3o pares v\u00e1lidos", + "bad_client_key": "Chave privada inv\u00e1lida, certifique-se de que um arquivo codificado PEM seja fornecido sem senha", + "bad_discovery_prefix": "Prefixo de descoberta inv\u00e1lido", + "bad_will": "T\u00f3pico de vontade inv\u00e1lido", + "cannot_connect": "Falha ao conectar", + "invalid_inclusion": "O certificado do cliente e a chave privada devem ser configurados juntos" }, "step": { "broker": { "data": { + "advanced_options": "Op\u00e7\u00f5es avan\u00e7adas", "broker": "Endere\u00e7o do Broker", + "certificate": "Caminho para o arquivo de certificado de CA personalizado", + "client_cert": "Caminho para um arquivo de certificado de cliente", + "client_id": "ID do cliente (deixe em branco para um gerado aleatoriamente)", + "client_key": "Caminho para um arquivo de chave privada", "discovery": "Ativar descoberta", + "keepalive": "O tempo entre o envio de mensagens de manuten\u00e7\u00e3o viva", "password": "Senha", "port": "Porta", + "protocol": "Protocolo MQTT", + "set_ca_cert": "Valida\u00e7\u00e3o do certificado do corretor", + "set_client_cert": "Usar um certificado de cliente", + "tls_insecure": "Ignorar a valida\u00e7\u00e3o do certificado do corretor", "username": "Usu\u00e1rio" }, "description": "Por favor, insira as informa\u00e7\u00f5es de conex\u00e3o do seu agente MQTT." @@ -53,20 +71,40 @@ "deprecated_yaml": { "description": "MQTT configurado manualmente {platform}(s) encontrado na chave de plataforma ` {platform} `. \n\n Por favor, mova a configura\u00e7\u00e3o para a chave de integra\u00e7\u00e3o `mqtt` e reinicie o Home Assistant para corrigir este problema. Consulte a [documenta\u00e7\u00e3o]( {more_info_url} ), para mais informa\u00e7\u00f5es.", "title": "Sua(s) {platform}(s) MQTT configurada(s) manualmente precisa(m) de aten\u00e7\u00e3o" + }, + "deprecated_yaml_broker_settings": { + "description": "As seguintes configura\u00e7\u00f5es encontradas em `configuration.yaml` foram migradas para a entrada de configura\u00e7\u00e3o MQTT e agora substituir\u00e3o as configura\u00e7\u00f5es em `configuration.yaml`:\n `{deprecated_settings}` \n\n Remova essas configura\u00e7\u00f5es de `configuration.yaml` e reinicie o Home Assistant para corrigir esse problema. Consulte a [documenta\u00e7\u00e3o]( {more_info_url} ), para mais informa\u00e7\u00f5es.", + "title": "Configura\u00e7\u00f5es de MQTT obsoletas encontradas em `configuration.yaml`" } }, "options": { "error": { - "bad_birth": "T\u00f3pico \u00b4Birth message\u00b4 inv\u00e1lido", - "bad_will": "T\u00f3pico \u00b4Will message\u00b4 inv\u00e1lido", - "cannot_connect": "Falha ao conectar" + "bad_birth": "T\u00f3pico de nascimento inv\u00e1lido", + "bad_certificate": "O certificado CA \u00e9 inv\u00e1lido", + "bad_client_cert": "Certificado de cliente inv\u00e1lido, certifique-se de que um arquivo codificado PEM seja fornecido", + "bad_client_cert_key": "Certificado de cliente e privado n\u00e3o s\u00e3o pares v\u00e1lidos", + "bad_client_key": "Chave privada inv\u00e1lida, certifique-se de que um arquivo codificado PEM seja fornecido sem senha", + "bad_discovery_prefix": "Prefixo de descoberta inv\u00e1lido", + "bad_will": "T\u00f3pico de vontade inv\u00e1lido", + "cannot_connect": "Falha ao conectar", + "invalid_inclusion": "O certificado do cliente e a chave privada devem ser configurados juntos" }, "step": { "broker": { "data": { + "advanced_options": "Op\u00e7\u00f5es avan\u00e7adas", "broker": "", + "certificate": "Carregar arquivo de certificado de CA personalizado", + "client_cert": "Carregar arquivo de certificado do cliente", + "client_id": "ID do cliente (deixe em branco para um gerado aleatoriamente)", + "client_key": "Carregar arquivo de chave privada", + "keepalive": "O tempo entre o envio de mensagens de manuten\u00e7\u00e3o viva", "password": "Senha", "port": "Porta", + "protocol": "Protocolo MQTT", + "set_ca_cert": "Valida\u00e7\u00e3o do certificado do corretor", + "set_client_cert": "Usar um certificado de cliente", + "tls_insecure": "Ignorar a valida\u00e7\u00e3o do certificado do corretor", "username": "Usu\u00e1rio" }, "description": "Insira as informa\u00e7\u00f5es de conex\u00e3o do seu broker MQTT.", @@ -80,13 +118,14 @@ "birth_retain": "Retain \u00b4Birth message\u00b4", "birth_topic": "T\u00f3pico \u00b4Birth message\u00b4", "discovery": "Ativar descoberta", + "discovery_prefix": "Prefixo de descoberta", "will_enable": "Ativar `Will message`", "will_payload": "Payload `Will message`", "will_qos": "QoS `Will message`", "will_retain": "Retain `Will message`", "will_topic": "T\u00f3pico `Will message`" }, - "description": "Descoberta - Se a descoberta estiver habilitada (recomendado), o Home Assistant descobrir\u00e1 automaticamente dispositivos e entidades que publicam suas configura\u00e7\u00f5es no broker MQTT. Se a descoberta estiver desabilitada, toda a configura\u00e7\u00e3o dever\u00e1 ser feita manualmente.\n\u00b4Birth message\u00b4 - Ser\u00e1 enviada sempre que o Home Assistant (re)conectar-se ao broker MQTT.\n`Will message` - Ser\u00e1 enviada sempre que o Home Assistant perder sua conex\u00e3o com o broker, tanto no caso de uma parada programada (por exemplo, o Home Assistant desligando) quanto no caso de uma parada inesperada (por exemplo, o Home Assistant travando ou perdendo sua conex\u00e3o de rede).", + "description": "Descoberta - Se a descoberta estiver habilitada (recomendado), o Home Assistant descobrir\u00e1 automaticamente dispositivos e entidades que publicam suas configura\u00e7\u00f5es no broker MQTT. Se a descoberta estiver desabilitada, toda a configura\u00e7\u00e3o dever\u00e1 ser feita manualmente.\n Prefixo de descoberta - O prefixo com o qual um t\u00f3pico de configura\u00e7\u00e3o para descoberta autom\u00e1tica deve come\u00e7ar.\n Mensagem de nascimento - A mensagem de nascimento ser\u00e1 enviada sempre que o Home Assistant (re)conectar-se ao broker MQTT.\n Mensagem de vontade - A mensagem de vontade ser\u00e1 enviada sempre que o Home Assistant perder sua conex\u00e3o com o corretor, tanto no caso de uma limpeza (por exemplo, o Home Assistant desligando) quanto no caso de uma limpeza (por exemplo, o Home Assistant travando ou perdendo sua conex\u00e3o de rede) desconectar.", "title": "Op\u00e7\u00f5es de MQTT" } } diff --git a/homeassistant/components/mqtt/translations/ru.json b/homeassistant/components/mqtt/translations/ru.json index c9b15c9489cec6bae5def4beaf9da84ab9e94738..f1f241e4c78c03a706ab9b2a4382fcec2c2be939 100644 --- a/homeassistant/components/mqtt/translations/ru.json +++ b/homeassistant/components/mqtt/translations/ru.json @@ -5,15 +5,30 @@ "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." }, "error": { - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + "bad_birth": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0442\u043e\u043f\u0438\u043a \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438.", + "bad_certificate": "\u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 \u0426\u0421 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.", + "bad_client_cert": "\u041d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 \u043a\u043b\u0438\u0435\u043d\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u0435\u043d \u0444\u0430\u0439\u043b, \u0437\u0430\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439 PEM", + "bad_client_cert_key": "\u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 \u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u0438 \u043f\u0440\u0438\u0432\u0430\u0442\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 \u043d\u0435 \u044f\u0432\u043b\u044f\u044e\u0442\u0441\u044f \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u043f\u0430\u0440\u043e\u0439.", + "bad_client_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0440\u0438\u0432\u0430\u0442\u043d\u044b\u0439 \u043a\u043b\u044e\u0447. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u0435\u043d \u0444\u0430\u0439\u043b \u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 PEM \u0431\u0435\u0437 \u043f\u0430\u0440\u043e\u043b\u044f.", + "bad_discovery_prefix": "\u041d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439 \u043f\u0440\u0435\u0444\u0438\u043a\u0441 \u0442\u043e\u043f\u0438\u043a\u0430 \u0430\u0432\u0442\u043e\u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f.", + "bad_will": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0442\u043e\u043f\u0438\u043a \u043e\u0431 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_inclusion": "\u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 \u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u0438 \u043f\u0440\u0438\u0432\u0430\u0442\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b \u0432\u043c\u0435\u0441\u0442\u0435." }, "step": { "broker": { "data": { + "advanced_options": "\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438", "broker": "\u0411\u0440\u043e\u043a\u0435\u0440", + "certificate": "\u041f\u0443\u0442\u044c \u043a \u0444\u0430\u0439\u043b\u0443 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u043e\u0433\u043e \u0426\u0421", + "client_cert": "\u041f\u0443\u0442\u044c \u043a \u0444\u0430\u0439\u043b\u0443 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430 \u043a\u043b\u0438\u0435\u043d\u0442\u0430", + "client_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043a\u043b\u0438\u0435\u043d\u0442\u0430 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c, \u0447\u0442\u043e\u0431\u044b \u0441\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u043c \u043e\u0431\u0440\u0430\u0437\u043e\u043c)", + "client_key": "\u041f\u0443\u0442\u044c \u043a \u0444\u0430\u0439\u043b\u0443 \u043f\u0440\u0438\u0432\u0430\u0442\u043d\u043e\u0433\u043e \u043a\u043b\u044e\u0447\u0430", "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0430\u0432\u0442\u043e\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432", + "keepalive": "\u0412\u0440\u0435\u043c\u044f \u043c\u0435\u0436\u0434\u0443 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u043e\u0439 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 Keep Alive", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442", + "protocol": "\u041f\u0440\u043e\u0442\u043e\u043a\u043e\u043b MQTT", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a \u0412\u0430\u0448\u0435\u043c\u0443 \u0431\u0440\u043e\u043a\u0435\u0440\u0443 MQTT." diff --git a/homeassistant/components/mqtt/translations/zh-Hant.json b/homeassistant/components/mqtt/translations/zh-Hant.json index ce101d389e88c9d0933b760f0dba64aacddca93d..c7d08ce74829fbffa12e03e8d662757739e3cb1b 100644 --- a/homeassistant/components/mqtt/translations/zh-Hant.json +++ b/homeassistant/components/mqtt/translations/zh-Hant.json @@ -5,6 +5,8 @@ "single_instance_allowed": "\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3001\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" }, "error": { + "bad_birth": "Birth \u4e3b\u984c\u7121\u6548", + "bad_certificate": "CA \u8a8d\u8b49\u7121\u6548", "cannot_connect": "\u9023\u7dda\u5931\u6557" }, "step": { diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py new file mode 100644 index 0000000000000000000000000000000000000000..5536d16d1c77eb5d35aadd8e67b76ffb2f745cf5 --- /dev/null +++ b/homeassistant/components/mqtt/update.py @@ -0,0 +1,263 @@ +"""Configure update platform in a device through MQTT topic.""" +from __future__ import annotations + +from collections.abc import Callable +import functools +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import update +from homeassistant.components.update import ( + DEVICE_CLASSES_SCHEMA, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.json import JSON_DECODE_EXCEPTIONS, json_loads +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import subscription +from .config import DEFAULT_RETAIN, MQTT_RO_SCHEMA +from .const import ( + CONF_COMMAND_TOPIC, + CONF_ENCODING, + CONF_QOS, + CONF_RETAIN, + CONF_STATE_TOPIC, + PAYLOAD_EMPTY_JSON, +) +from .debug_info import log_messages +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .models import MqttValueTemplate, ReceiveMessage +from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "MQTT Update" + +CONF_ENTITY_PICTURE = "entity_picture" +CONF_LATEST_VERSION_TEMPLATE = "latest_version_template" +CONF_LATEST_VERSION_TOPIC = "latest_version_topic" +CONF_PAYLOAD_INSTALL = "payload_install" +CONF_RELEASE_SUMMARY = "release_summary" +CONF_RELEASE_URL = "release_url" +CONF_TITLE = "title" + + +PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_ENTITY_PICTURE): cv.string, + vol.Optional(CONF_LATEST_VERSION_TEMPLATE): cv.template, + vol.Optional(CONF_LATEST_VERSION_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_INSTALL): cv.string, + vol.Optional(CONF_RELEASE_SUMMARY): cv.string, + vol.Optional(CONF_RELEASE_URL): cv.string, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_TITLE): cv.string, + }, +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT update through configuration.yaml and dynamically through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, update.DOMAIN, setup, DISCOVERY_SCHEMA) + + +async def _async_setup_entity( + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None = None, +) -> None: + """Set up the MQTT update.""" + async_add_entities([MqttUpdate(hass, config, config_entry, discovery_data)]) + + +class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): + """Representation of the MQTT update entity.""" + + _entity_id_format = update.ENTITY_ID_FORMAT + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None = None, + ) -> None: + """Initialize the MQTT update.""" + self._config = config + self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) + self._attr_release_summary = self._config.get(CONF_RELEASE_SUMMARY) + self._attr_release_url = self._config.get(CONF_RELEASE_URL) + self._attr_title = self._config.get(CONF_TITLE) + self._entity_picture: str | None = self._config.get(CONF_ENTITY_PICTURE) + + UpdateEntity.__init__(self) + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend.""" + if self._entity_picture is not None: + return self._entity_picture + + return super().entity_picture + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._templates = { + CONF_VALUE_TEMPLATE: MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + ).async_render_with_possible_json_value, + CONF_LATEST_VERSION_TEMPLATE: MqttValueTemplate( + config.get(CONF_LATEST_VERSION_TEMPLATE), + entity=self, + ).async_render_with_possible_json_value, + } + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + topics: dict[str, Any] = {} + + def add_subscription( + topics: dict[str, Any], topic: str, msg_callback: Callable + ) -> None: + if self._config.get(topic) is not None: + topics[topic] = { + "topic": self._config[topic], + "msg_callback": msg_callback, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + + @callback + @log_messages(self.hass, self.entity_id) + def handle_state_message_received(msg: ReceiveMessage) -> None: + """Handle receiving state message via MQTT.""" + payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) + + if not payload or payload == PAYLOAD_EMPTY_JSON: + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + + json_payload = {} + try: + json_payload = json_loads(payload) + _LOGGER.debug( + "JSON payload detected after processing payload '%s' on topic %s", + json_payload, + msg.topic, + ) + except JSON_DECODE_EXCEPTIONS: + _LOGGER.debug( + "No valid (JSON) payload detected after processing payload '%s' on topic %s", + payload, + msg.topic, + ) + json_payload["installed_version"] = payload + + if "installed_version" in json_payload: + self._attr_installed_version = json_payload["installed_version"] + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + if "latest_version" in json_payload: + self._attr_latest_version = json_payload["latest_version"] + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + if CONF_TITLE in json_payload and not self._attr_title: + self._attr_title = json_payload[CONF_TITLE] + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + if CONF_RELEASE_SUMMARY in json_payload and not self._attr_release_summary: + self._attr_release_summary = json_payload[CONF_RELEASE_SUMMARY] + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + if CONF_RELEASE_URL in json_payload and not self._attr_release_url: + self._attr_release_url = json_payload[CONF_RELEASE_URL] + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + if CONF_ENTITY_PICTURE in json_payload and not self._entity_picture: + self._entity_picture = json_payload[CONF_ENTITY_PICTURE] + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + add_subscription(topics, CONF_STATE_TOPIC, handle_state_message_received) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_latest_version_received(msg: ReceiveMessage) -> None: + """Handle receiving latest version via MQTT.""" + latest_version = self._templates[CONF_LATEST_VERSION_TEMPLATE](msg.payload) + + if isinstance(latest_version, str) and latest_version != "": + self._attr_latest_version = latest_version + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + add_subscription( + topics, CONF_LATEST_VERSION_TOPIC, handle_latest_version_received + ) + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, self._sub_state, topics + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Update the current value.""" + payload = self._config[CONF_PAYLOAD_INSTALL] + + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + support = 0 + + if self._config.get(CONF_COMMAND_TOPIC) is not None: + support |= UpdateEntityFeature.INSTALL + + return support diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 43734872e14287a9545d944713a1b317137dfe5c..0b2d10977aa3343af52227504df1f27410086e9a 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -2,6 +2,9 @@ from __future__ import annotations +import os +from pathlib import Path +import tempfile from typing import Any import voluptuous as vol @@ -9,19 +12,26 @@ import voluptuous as vol from homeassistant.const import CONF_PAYLOAD from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_PAYLOAD, ATTR_QOS, ATTR_RETAIN, ATTR_TOPIC, + CONF_CERTIFICATE, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, DATA_MQTT, + DEFAULT_ENCODING, DEFAULT_QOS, DEFAULT_RETAIN, DOMAIN, ) from .models import MqttData +TEMP_DIR_NAME = f"home-assistant-{DOMAIN}" + def mqtt_config_entry_enabled(hass: HomeAssistant) -> bool | None: """Return true when the MQTT config entry is enabled.""" @@ -30,58 +40,58 @@ def mqtt_config_entry_enabled(hass: HomeAssistant) -> bool | None: return not bool(hass.config_entries.async_entries(DOMAIN)[0].disabled_by) -def valid_topic(value: Any) -> str: +def valid_topic(topic: Any) -> str: """Validate that this is a valid topic name/filter.""" - value = cv.string(value) + validated_topic = cv.string(topic) try: - raw_value = value.encode("utf-8") + raw_validated_topic = validated_topic.encode("utf-8") except UnicodeError as err: raise vol.Invalid("MQTT topic name/filter must be valid UTF-8 string.") from err - if not raw_value: + if not raw_validated_topic: raise vol.Invalid("MQTT topic name/filter must not be empty.") - if len(raw_value) > 65535: + if len(raw_validated_topic) > 65535: raise vol.Invalid( "MQTT topic name/filter must not be longer than 65535 encoded bytes." ) - if "\0" in value: + if "\0" in validated_topic: raise vol.Invalid("MQTT topic name/filter must not contain null character.") - if any(char <= "\u001F" for char in value): + if any(char <= "\u001F" for char in validated_topic): raise vol.Invalid("MQTT topic name/filter must not contain control characters.") - if any("\u007f" <= char <= "\u009F" for char in value): + if any("\u007f" <= char <= "\u009F" for char in validated_topic): raise vol.Invalid("MQTT topic name/filter must not contain control characters.") - if any("\ufdd0" <= char <= "\ufdef" for char in value): + if any("\ufdd0" <= char <= "\ufdef" for char in validated_topic): raise vol.Invalid("MQTT topic name/filter must not contain non-characters.") - if any((ord(char) & 0xFFFF) in (0xFFFE, 0xFFFF) for char in value): + if any((ord(char) & 0xFFFF) in (0xFFFE, 0xFFFF) for char in validated_topic): raise vol.Invalid("MQTT topic name/filter must not contain noncharacters.") - return value + return validated_topic -def valid_subscribe_topic(value: Any) -> str: +def valid_subscribe_topic(topic: Any) -> str: """Validate that we can subscribe using this MQTT topic.""" - value = valid_topic(value) - for i in (i for i, c in enumerate(value) if c == "+"): - if (i > 0 and value[i - 1] != "/") or ( - i < len(value) - 1 and value[i + 1] != "/" + validated_topic = valid_topic(topic) + for i in (i for i, c in enumerate(validated_topic) if c == "+"): + if (i > 0 and validated_topic[i - 1] != "/") or ( + i < len(validated_topic) - 1 and validated_topic[i + 1] != "/" ): raise vol.Invalid( "Single-level wildcard must occupy an entire level of the filter" ) - index = value.find("#") + index = validated_topic.find("#") if index != -1: - if index != len(value) - 1: + if index != len(validated_topic) - 1: # If there are multiple wildcards, this will also trigger raise vol.Invalid( "Multi-level wildcard must be the last " "character in the topic filter." ) - if len(value) > 1 and value[index - 1] != "/": + if len(validated_topic) > 1 and validated_topic[index - 1] != "/": raise vol.Invalid( "Multi-level wildcard must be after a topic level separator." ) - return value + return validated_topic def valid_subscribe_topic_template(value: Any) -> template.Template: @@ -94,12 +104,12 @@ def valid_subscribe_topic_template(value: Any) -> template.Template: return tpl -def valid_publish_topic(value: Any) -> str: +def valid_publish_topic(topic: Any) -> str: """Validate that we can publish using this MQTT topic.""" - value = valid_topic(value) - if "+" in value or "#" in value: + validated_topic = valid_topic(topic) + if "+" in validated_topic or "#" in validated_topic: raise vol.Invalid("Wildcards can not be used in topic names") - return value + return validated_topic _VALID_QOS_SCHEMA = vol.All(vol.Coerce(int), vol.In([0, 1, 2])) @@ -120,3 +130,57 @@ def get_mqtt_data(hass: HomeAssistant, ensure_exists: bool = False) -> MqttData: if ensure_exists: return hass.data.setdefault(DATA_MQTT, MqttData()) return hass.data[DATA_MQTT] + + +async def async_create_certificate_temp_files( + hass: HomeAssistant, config: ConfigType +) -> None: + """Create certificate temporary files for the MQTT client.""" + + def _create_temp_file(temp_file: Path, data: str | None) -> None: + if data is None or data == "auto": + if temp_file.exists(): + os.remove(Path(temp_file)) + return + temp_file.write_text(data) + + def _create_temp_dir_and_files() -> None: + """Create temporary directory.""" + temp_dir = Path(tempfile.gettempdir()) / TEMP_DIR_NAME + + if ( + config.get(CONF_CERTIFICATE) + or config.get(CONF_CLIENT_CERT) + or config.get(CONF_CLIENT_KEY) + ) and not temp_dir.exists(): + temp_dir.mkdir(0o700) + + _create_temp_file(temp_dir / CONF_CERTIFICATE, config.get(CONF_CERTIFICATE)) + _create_temp_file(temp_dir / CONF_CLIENT_CERT, config.get(CONF_CLIENT_CERT)) + _create_temp_file(temp_dir / CONF_CLIENT_KEY, config.get(CONF_CLIENT_KEY)) + + await hass.async_add_executor_job(_create_temp_dir_and_files) + + +def get_file_path(option: str, default: str | None = None) -> Path | str | None: + """Get file path of a certificate file.""" + temp_dir = Path(tempfile.gettempdir()) / TEMP_DIR_NAME + if not temp_dir.exists(): + return default + + file_path: Path = temp_dir / option + if not file_path.exists(): + return default + + return temp_dir / option + + +def migrate_certificate_file_to_content(file_name_or_auto: str) -> str | None: + """Convert certificate file or setting to config entry setting.""" + if file_name_or_auto == "auto": + return "auto" + try: + with open(file_name_or_auto, encoding=DEFAULT_ENCODING) as certiticate_file: + return certiticate_file.read() + except OSError: + return None diff --git a/homeassistant/components/mullvad/translations/nb.json b/homeassistant/components/mullvad/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/mullvad/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mutesync/translations/bg.json b/homeassistant/components/mutesync/translations/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..dcdcdcfc1867a0242e0857a3d0335e1802945947 --- /dev/null +++ b/homeassistant/components/mutesync/translations/bg.json @@ -0,0 +1,15 @@ +{ + "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": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mutesync/translations/nb.json b/homeassistant/components/mutesync/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/mutesync/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index 089d7ac5fbc21d228a13e604b302d790a2a61f00..0f0d32908efc4a8263071d48c4e437d89d4feed1 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -9,7 +9,7 @@ import MVGLive import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES +from homeassistant.const import CONF_NAME, TIME_MINUTES from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -90,6 +90,8 @@ def setup_platform( class MVGLiveSensor(SensorEntity): """Implementation of an MVG Live sensor.""" + _attr_attribution = ATTRIBUTION + def __init__( self, station, @@ -210,7 +212,7 @@ class MVGLiveData: continue # now select the relevant data - _nextdep = {ATTR_ATTRIBUTION: ATTRIBUTION} + _nextdep = {} for k in ("destination", "linename", "time", "direction", "product"): _nextdep[k] = _departure.get(k, "") _nextdep["time"] = int(_nextdep["time"]) diff --git a/homeassistant/components/myq/translations/bg.json b/homeassistant/components/myq/translations/bg.json index 9c1d3ecccb8b5f9c0b04a89d4e56cdee98bca22b..728682f531e847985cb92dc2b904c8f3bb39f23c 100644 --- a/homeassistant/components/myq/translations/bg.json +++ b/homeassistant/components/myq/translations/bg.json @@ -1,12 +1,18 @@ { "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" + "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", + "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": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + }, "user": { "data": { "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" diff --git a/homeassistant/components/myq/translations/nb.json b/homeassistant/components/myq/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/myq/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/myq/translations/no.json b/homeassistant/components/myq/translations/no.json index b43115f6b93de4ec750f9825d8244728af40c1bf..81021c1c0bee98dba534f57eec760b31df43bed5 100644 --- a/homeassistant/components/myq/translations/no.json +++ b/homeassistant/components/myq/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Tjenesten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index 435bf2ffddbb9cc63d1995b543acb44146a5c050..44468d8db4c573f5c492e998d993ead0b86cc98a 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -20,6 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.unit_system import METRIC_SYSTEM from .. import mysensors from .const import MYSENSORS_DISCOVERY, DiscoveryInfo @@ -94,7 +95,9 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity): @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - return TEMP_CELSIUS if self.hass.config.units.is_metric else TEMP_FAHRENHEIT + return ( + TEMP_CELSIUS if self.hass.config.units is METRIC_SYSTEM else TEMP_FAHRENHEIT + ) @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index c93e038075746f291c001bc37542630e7fbf3c92..a920faf49fa13a311f048cf9bca82923d3130974 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -13,8 +13,8 @@ import async_timeout from mysensors import BaseAsyncGateway, Message, Sensor, mysensors import voluptuous as vol -from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN -from homeassistant.components.mqtt.models import ( +from homeassistant.components.mqtt import ( + DOMAIN as MQTT_DOMAIN, ReceiveMessage as MQTTReceiveMessage, ReceivePayloadType, ) @@ -22,6 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( CONF_BAUD_RATE, @@ -220,7 +221,7 @@ async def _get_gateway( protocol_version=version, ) gateway.event_callback = event_callback - gateway.metric = hass.config.units.is_metric + gateway.metric = hass.config.units is METRIC_SYSTEM if persistence: await gateway.start_persistence() diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index f21d343f9c35f26c2786191a314405284b6f60da..5bafed04353e5407e78226564cf1bb56f37d2e29 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -35,6 +35,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.unit_system import METRIC_SYSTEM from .. import mysensors from .const import MYSENSORS_DISCOVERY, DiscoveryInfo @@ -242,7 +243,7 @@ class MySensorsSensor(mysensors.device.MySensorsEntity, SensorEntity): return custom_unit if set_req(self.value_type) == set_req.V_TEMP: - if self.hass.config.units.is_metric: + if self.hass.config.units is METRIC_SYSTEM: return TEMP_CELSIUS return TEMP_FAHRENHEIT diff --git a/homeassistant/components/mysensors/translations/nb.json b/homeassistant/components/mysensors/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/mysensors/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 25615db6eede6e929bbbe7bb412d460b1023f39a..0fbc93846345c2b6a86e6298df8e2a809ce52749 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -56,6 +56,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await nam.async_check_credentials() + except ApiError as err: + raise ConfigEntryNotReady from err except AuthFailed as err: raise ConfigEntryAuthFailed from err diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index b70c2054808cfa29507956c93fb3a6b0cd32c143..43c217e2a4d481ce4409db43d126a40fe42fc6bb 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -17,5 +17,6 @@ "config_flow": true, "quality_scale": "platinum", "iot_class": "local_polling", - "loggers": ["nettigo_air_monitor"] + "loggers": ["nettigo_air_monitor"], + "integration_type": "device" } diff --git a/homeassistant/components/nam/translations/bg.json b/homeassistant/components/nam/translations/bg.json index 50368ce880db0fe65afc1d932a8071b4112e7791..9be1a75603acbd0eb44875c148178317b4565f86 100644 --- a/homeassistant/components/nam/translations/bg.json +++ b/homeassistant/components/nam/translations/bg.json @@ -4,7 +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", "device_unsupported": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\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", - "reauth_unsuccessful": "\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 \u043d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e, \u043c\u043e\u043b\u044f, \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0435\u0442\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0438 \u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e." + "reauth_unsuccessful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u0442\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0431\u0435\u0448\u0435 \u043d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u0430, \u043c\u043e\u043b\u044f, \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0435\u0442\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0438 \u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e." }, "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", diff --git a/homeassistant/components/nam/translations/nb.json b/homeassistant/components/nam/translations/nb.json index 5d04c17f9326fd2c232528f5dce71809dc08e8a9..bff53ea890245ad2a3895c81c7cba7a16a4bbcb4 100644 --- a/homeassistant/components/nam/translations/nb.json +++ b/homeassistant/components/nam/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "credentials": { "data": { diff --git a/homeassistant/components/nam/translations/no.json b/homeassistant/components/nam/translations/no.json index f66a6b148e3c6468460216221d051f011123bf30..758ea3c4aba0420663f8a8b8625da19395923f8d 100644 --- a/homeassistant/components/nam/translations/no.json +++ b/homeassistant/components/nam/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Enheten er allerede konfigurert", "device_unsupported": "Enheten st\u00f8ttes ikke.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "reauth_unsuccessful": "Re-autentisering mislyktes. Fjern integrasjonen og konfigurer den p\u00e5 nytt." }, "error": { diff --git a/homeassistant/components/nanoleaf/translations/nb.json b/homeassistant/components/nanoleaf/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/nanoleaf/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/neato/translations/no.json b/homeassistant/components/neato/translations/no.json index d62c8e5d1f16217f5d8b429a2af7eb7279642727..3c9ce3fb76da4d8136e31fd0d4a1947d70b511d8 100644 --- a/homeassistant/components/neato/translations/no.json +++ b/homeassistant/components/neato/translations/no.json @@ -5,7 +5,7 @@ "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})", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "create_entry": { "default": "Vellykket godkjenning" diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 063fd12f5e0df2a545a4ad71ac57e95420c3e717..4d7a652601c5034f9ce10ff083f956ba8ab795f2 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -10,7 +10,7 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -20,8 +20,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by NS" - CONF_ROUTES = "routes" CONF_FROM = "from" CONF_TO = "to" @@ -88,8 +86,7 @@ def setup_platform( departure.get(CONF_TIME), ) ) - if sensors: - add_entities(sensors, True) + add_entities(sensors, True) def valid_stations(stations, given_stations): @@ -106,6 +103,8 @@ def valid_stations(stations, given_stations): class NSDepartureSensor(SensorEntity): """Implementation of a NS Departure Sensor.""" + _attr_attribution = "Data provided by NS" + def __init__(self, nsapi, name, departure, heading, via, time): """Initialize the sensor.""" self._nsapi = nsapi @@ -161,7 +160,6 @@ class NSDepartureSensor(SensorEntity): "transfers": self._trips[0].nr_transfers, "route": route, "remarks": None, - ATTR_ATTRIBUTION: ATTRIBUTION, } # Planned departure attributes diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py index dd257bb9301f21b35b28e8b71d43fef1029ddaf2..bed44045c113fb50da1402f9a2d6ebe9c8f565ba 100644 --- a/homeassistant/components/nest/climate_sdm.py +++ b/homeassistant/components/nest/climate_sdm.py @@ -117,6 +117,11 @@ class ThermostatEntity(ClimateEntity): """Return device specific attributes.""" return self._device_info.device_info + @property + def available(self) -> bool: + """Return device availability.""" + return self._device_info.available + async def async_added_to_hass(self) -> None: """Run when entity is added to register update signal handler.""" self._attr_supported_features = self._get_supported_features() diff --git a/homeassistant/components/nest/const.py b/homeassistant/components/nest/const.py index 64c27c1643be78365290c8da29890842d0153ad5..853e778977d35a0a72faaa0dc1fd5bdd22c062a4 100644 --- a/homeassistant/components/nest/const.py +++ b/homeassistant/components/nest/const.py @@ -14,6 +14,8 @@ CONF_SUBSCRIBER_ID = "subscriber_id" CONF_SUBSCRIBER_ID_IMPORTED = "subscriber_id_imported" CONF_CLOUD_PROJECT_ID = "cloud_project_id" +CONNECTIVITY_TRAIT_OFFLINE = "OFFLINE" + SIGNAL_NEST_UPDATE = "nest_update" # For the Google Nest Device Access API diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 2d2b01d3849255f611d47830d95259b21302330f..e269b76fcc4be597ea2c69d1d41dd6e028d26104 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -5,13 +5,13 @@ 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 google_nest_sdm.device_traits import ConnectivityTrait, InfoTrait from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo -from .const import DATA_DEVICE_MANAGER, DOMAIN +from .const import CONNECTIVITY_TRAIT_OFFLINE, DATA_DEVICE_MANAGER, DOMAIN DEVICE_TYPE_MAP: dict[str, str] = { "sdm.devices.types.CAMERA": "Camera", @@ -30,6 +30,15 @@ class NestDeviceInfo: """Initialize the DeviceInfo.""" self._device = device + @property + def available(self) -> bool: + """Return device availability.""" + if ConnectivityTrait.NAME in self._device.traits: + trait: ConnectivityTrait = self._device.traits[ConnectivityTrait.NAME] + if trait.status == CONNECTIVITY_TRAIT_OFFLINE: + return False + return True + @property def device_info(self) -> DeviceInfo: """Return device specific attributes.""" diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index 11edc9f3506fc32c1e8af236d1cc3d8201497d0c..b36e91031960ffe0734bca7e9b0ef0e520bd07cb 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -62,6 +62,11 @@ class SensorBase(SensorEntity): self._attr_unique_id = f"{device.name}-{self.device_class}" self._attr_device_info = self._device_info.device_info + @property + def available(self) -> bool: + """Return the device availability.""" + return self._device_info.available + async def async_added_to_hass(self) -> None: """Run when entity is added to register update signal handler.""" self.async_on_remove( diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 07ba63ac479fe417759b9498c8487b8aab40f3c4..bf68d1988d63e6b2b64195d142d9227f236c3187 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -25,7 +25,7 @@ }, "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", + "description": "Create a Nest Device Access project which **requires paying Google 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" } diff --git a/homeassistant/components/nest/translations/bg.json b/homeassistant/components/nest/translations/bg.json index 41f20ca4a5f0f81482972951f73cd72320fa57c5..ed5adaef5e6577ba8024b56cad1c826e9804cf98 100644 --- a/homeassistant/components/nest/translations/bg.json +++ b/homeassistant/components/nest/translations/bg.json @@ -27,6 +27,9 @@ "description": "\u0417\u0430 \u0434\u0430 \u0441\u0432\u044a\u0440\u0436\u0435\u0442\u0435 \u043f\u0440\u043e\u0444\u0438\u043b\u0430 \u0441\u0438 \u0432 Nest, [\u043e\u0442\u043e\u0440\u0438\u0437\u0438\u0440\u0430\u0439\u0442\u0435 \u043f\u0440\u043e\u0444\u0438\u043b\u0430 \u0441\u0438]({url}). \n\n \u0421\u043b\u0435\u0434 \u043a\u0430\u0442\u043e \u0441\u0442\u0435 \u043e\u0442\u043e\u0440\u0438\u0437\u0438\u0440\u0430\u043d\u0435, \u043a\u043e\u043f\u0438\u0440\u0430\u0439\u0442\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u043d\u0438\u044f \u043f\u043e-\u0434\u043e\u043b\u0443 PIN \u043a\u043e\u0434.", "title": "\u0421\u0432\u044a\u0440\u0436\u0435\u0442\u0435 \u0412\u0430\u0448\u0438\u044f \u043f\u0440\u043e\u0444\u0438\u043b \u0432 Nest" }, + "pubsub": { + "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 Google Cloud" + }, "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" } diff --git a/homeassistant/components/nest/translations/ca.json b/homeassistant/components/nest/translations/ca.json index 20fc09c1af35f17094cfce8d2bc69ef2ef5a0623..e3d55a7c84dd682d9e967d9b6ec2055291d26865 100644 --- a/homeassistant/components/nest/translations/ca.json +++ b/homeassistant/components/nest/translations/ca.json @@ -52,7 +52,7 @@ "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", + "description": "Crea un projecte d'acc\u00e9s a dispositius Nest que **implica el pagament a Google 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": { diff --git a/homeassistant/components/nest/translations/de.json b/homeassistant/components/nest/translations/de.json index 18ecafda58e1a06c6896b565c347c9a0c0776684..efe0ee8c1154e6cd88c560b4a10e2958b073a7d2 100644 --- a/homeassistant/components/nest/translations/de.json +++ b/homeassistant/components/nest/translations/de.json @@ -52,7 +52,7 @@ "data": { "project_id": "Ger\u00e4tezugriffsprojekt ID" }, - "description": "Erstelle ein Nest Ger\u00e4tezugriffsprojekt, f\u00fcr dessen Einrichtung **eine Geb\u00fchr von 5 US-Dollar** anf\u00e4llt.\n1. Gehe zur [Device Access Console]({device_access_console_url}) und durchlaufe den Zahlungsablauf.\n1. Dr\u00fccke auf **Projekt erstellen**.\n1. Gib deinem Device Access-Projekt einen Namen und dr\u00fccke auf **Weiter**.\n1. Gib deine OAuth-Client-ID ein\n1. Aktiviere Ereignisse, indem du auf **Aktivieren** und **Projekt erstellen** dr\u00fcckst.\n\nGib unten deine Ger\u00e4tezugriffsprojekt ID ein ([more info]({more_info_url})).", + "description": "Erstelle ein Nest Device Access-Projekt, f\u00fcr dessen Einrichtung **eine Geb\u00fchr von 5 US-Dollar an Google zu zahlen ist**.\n 1. Gehe zur [Ger\u00e4tezugriffskonsole] ( {device_access_console_url} ) und durch den Zahlungsablauf.\n 1. Klicke auf **Projekt erstellen**\n 1. Gib deinem Device Access-Projekt einen Namen und klicke auf **Weiter**.\n 1. Gib deine OAuth-Client-ID ein\n 1. Aktiviere Ereignisse, indem du auf **Aktivieren** und **Projekt erstellen** klickst. \n\n Gib unten deine Projekt-ID f\u00fcr den Ger\u00e4tezugriff ein ([weitere Informationen]( {more_info_url} )).", "title": "Nest: Erstelle ein Ger\u00e4tezugriffsprojekt" }, "device_project_upgrade": { diff --git a/homeassistant/components/nest/translations/en.json b/homeassistant/components/nest/translations/en.json index e0c0b8e67a5bdf8a965edce92d257d77d21df771..0767822754747f51d1d0a698dbd5449e9cca47c2 100644 --- a/homeassistant/components/nest/translations/en.json +++ b/homeassistant/components/nest/translations/en.json @@ -52,7 +52,7 @@ "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", + "description": "Create a Nest Device Access project which **requires paying Google 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": { diff --git a/homeassistant/components/nest/translations/es.json b/homeassistant/components/nest/translations/es.json index 3cd92fd644dfb258707e50eebad56d40d7b36bef..95c8aa5ef04973595394a74d18e5b00f72f53095 100644 --- a/homeassistant/components/nest/translations/es.json +++ b/homeassistant/components/nest/translations/es.json @@ -52,7 +52,7 @@ "data": { "project_id": "ID de proyecto de acceso a dispositivos" }, - "description": "Crea un proyecto de acceso a dispositivos Nest que **requiere una cuota de 5$** para configurarlo.\n 1. Ve a la [Consola de acceso al dispositivo]({device_access_console_url}) y sigue el flujo de pago.\n 1. Haz clic en **Crear proyecto**\n 1. Asigna un nombre a tu proyecto de acceso a dispositivos y haz clic en **Siguiente**.\n 1. Introduce tu ID de cliente de OAuth\n 1. Habilita los eventos haciendo clic en **Habilitar** y **Crear proyecto**. \n\n Introduce tu ID de proyecto de acceso a dispositivos a continuaci\u00f3n ([m\u00e1s informaci\u00f3n]({more_info_url})).", + "description": "Crea un proyecto de acceso a dispositivos Nest que **requiere pagarle a Google una tarifa de 5$ US** para configurarlo.\n1. Ve a la [Consola de acceso al dispositivo]({device_access_console_url}) y sigue el flujo de pago.\n1. Haz clic en **Crear proyecto**\n1. Asigna un nombre a tu proyecto de Acceso al dispositivo y haz clic en **Siguiente**.\n1. Introduce tu ID de cliente de OAuth\n1. Habilita los eventos haciendo clic en **Habilitar** y **Crear proyecto**. \n\nIntroduce tu ID de proyecto de acceso a dispositivos a continuaci\u00f3n ([m\u00e1s informaci\u00f3n]({more_info_url})).\n", "title": "Nest: Crear un proyecto de acceso a dispositivos" }, "device_project_upgrade": { diff --git a/homeassistant/components/nest/translations/et.json b/homeassistant/components/nest/translations/et.json index b79c320a35bd91d1b467ea2d888613e11bed16d1..655515e93f812b4b4ee1f853c4c3da72b57e5fed 100644 --- a/homeassistant/components/nest/translations/et.json +++ b/homeassistant/components/nest/translations/et.json @@ -52,7 +52,7 @@ "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", + "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})).", "title": "Nest: seadmele juurdep\u00e4\u00e4su projekti loomine" }, "device_project_upgrade": { diff --git a/homeassistant/components/nest/translations/hu.json b/homeassistant/components/nest/translations/hu.json index f453fdef150431c6560157c6e944f00b437b9c45..6f868eb7aab71f74fde5e2bcd1081199442a57c3 100644 --- a/homeassistant/components/nest/translations/hu.json +++ b/homeassistant/components/nest/translations/hu.json @@ -52,7 +52,7 @@ "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", + "description": "Hozzon l\u00e9tre egy Nest Device Access projektet, amelynek be\u00e1ll\u00edt\u00e1sa **5 USD d\u00edjat** ig\u00e9nyel.\n1. L\u00e1togasson el a [Device Access Console]({device_access_console_url}) oldalra, \u00e9s a fizet\u00e9si folyamaton menjen kereszt\u00fcl.\n1. Kattintson a **Projekt l\u00e9trehoz\u00e1sa** gombra.\n1. Adjon nevet a projektnek, \u00e9s kattintson a **K\u00f6vetkez\u0151** gombra.\n1. Adja meg az OAuth \u00fcgyf\u00e9l azonos\u00edt\u00f3j\u00e1t (Client ID)\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": { diff --git a/homeassistant/components/nest/translations/id.json b/homeassistant/components/nest/translations/id.json index d9ed203980065da73b222047487d916a21f9432a..9401016b99025c1162336721aa9f97f173bad8d3 100644 --- a/homeassistant/components/nest/translations/id.json +++ b/homeassistant/components/nest/translations/id.json @@ -52,7 +52,7 @@ "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", + "description": "Buat proyek Akses Perangkat Nest yang **membutuhkan biaya USD5 untuk Google** 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": { @@ -99,8 +99,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Proses konfigurasi Nest di configuration.yaml sedang dihapus di Home Assistant 2022.10. \n\nKredensial Aplikasi OAuth yang Anda dan setelan akses telah diimpor ke antarmuka secara otomatis. Hapus konfigurasi YAML dari file configuration.yaml Anda dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Nest dalam proses penghapusan" + "description": "Proses konfigurasi Integrasi Nest di configuration.yaml sedang dihapus di Home Assistant 2022.10. \n\nKredensial Aplikasi OAuth yang Anda dan setelan akses telah diimpor ke antarmuka secara otomatis. Hapus konfigurasi YAML dari file configuration.yaml Anda dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Nest dalam proses penghapusan" }, "removed_app_auth": { "description": "Untuk meningkatkan keamanan dan mengurangi risiko phishing, Google telah menghentikan metode autentikasi yang digunakan oleh Home Assistant.\n\n**Tindakan berikut diperlukan untuk diselesaikan** ([info lebih lanjut]({more_info_url}))\n\n1. Kunjungi halaman integrasi\n1. Klik Konfigurasi Ulang pada integrasi Nest.\n1. Home Assistant akan memandu Anda melalui langkah-langkah untuk meningkatkan ke Autentikasi Web.\n\nLihat [instruksi integrasi]({documentation_url}) Nest untuk informasi pemecahan masalah.", diff --git a/homeassistant/components/nest/translations/it.json b/homeassistant/components/nest/translations/it.json index 73095bf093073051276d4b941e9b2547e84a2687..6cdfa35b194efc420891cb39edc5d1b023b1d76a 100644 --- a/homeassistant/components/nest/translations/it.json +++ b/homeassistant/components/nest/translations/it.json @@ -52,7 +52,7 @@ "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", + "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": { diff --git a/homeassistant/components/nest/translations/nb.json b/homeassistant/components/nest/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/nest/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/translations/no.json b/homeassistant/components/nest/translations/no.json index 89ba7bc8b7c17c2774129da10b64803b2eff8c7f..c72577c17443f71a79210c17caa0e8998f7ce078 100644 --- a/homeassistant/components/nest/translations/no.json +++ b/homeassistant/components/nest/translations/no.json @@ -9,7 +9,7 @@ "invalid_access_token": "Ugyldig tilgangstoken", "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})", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig.", "unknown_authorize_url_generation": "Ukjent feil under generering av en autoriserings-URL." }, @@ -52,7 +52,7 @@ "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", + "description": "Opprett et Nest Device Access-prosjekt som **krever \u00e5 betale Google 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": { diff --git a/homeassistant/components/nest/translations/pl.json b/homeassistant/components/nest/translations/pl.json index a0b879ccc904188479699307e35bd81250cc558d..11da4335614b4b5feaf226fb97838b6dc31f0492 100644 --- a/homeassistant/components/nest/translations/pl.json +++ b/homeassistant/components/nest/translations/pl.json @@ -52,7 +52,7 @@ "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", + "description": "Utw\u00f3rz projekt dost\u0119pu do urz\u0105dzenia Nest, kt\u00f3rego konfiguracja **wymaga op\u0142aty w Google 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": { diff --git a/homeassistant/components/nest/translations/pt-BR.json b/homeassistant/components/nest/translations/pt-BR.json index 9f5f9b9eff7440839ede3c075ed1a6d40736db20..74f68f0177570768d85d866513c260ebdde2d2fa 100644 --- a/homeassistant/components/nest/translations/pt-BR.json +++ b/homeassistant/components/nest/translations/pt-BR.json @@ -52,7 +52,7 @@ "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} )).", + "description": "Crie um projeto Nest Device Access que **exije o pagamento de uma taxa de US$ 5 ao Google** 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 **Next**.\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})).\n", "title": "Nest: criar um projeto de acesso ao dispositivo" }, "device_project_upgrade": { diff --git a/homeassistant/components/nest/translations/ru.json b/homeassistant/components/nest/translations/ru.json index 44295514840e81aa65c074b915810d102dfee254..5a995fb35aedaa5e45dfe0d05960726db7ce4d20 100644 --- a/homeassistant/components/nest/translations/ru.json +++ b/homeassistant/components/nest/translations/ru.json @@ -52,7 +52,7 @@ "data": { "project_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043f\u0440\u043e\u0435\u043a\u0442\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, - "description": "\u0421\u043e\u0437\u0434\u0430\u0439\u0442\u0435 \u043f\u0440\u043e\u0435\u043a\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443, \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e **\u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u043b\u0430\u0442\u0430 \u0432 \u0440\u0430\u0437\u043c\u0435\u0440\u0435 5 \u0434\u043e\u043b\u043b\u0430\u0440\u043e\u0432 \u0421\u0428\u0410**.\n1. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u0432 [\u041a\u043e\u043d\u0441\u043e\u043b\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430\u043c]({device_access_console_url}) \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u043f\u0440\u043e\u0446\u0435\u0441\u0441 \u043e\u043f\u043b\u0430\u0442\u044b.\n2. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 **Create project**.\n3. \u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0435\u043a\u0442\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 **Next**.\n4. \u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0441\u0432\u043e\u0439 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043a\u043b\u0438\u0435\u043d\u0442\u0430 OAuth.\n5. \u0410\u043a\u0442\u0438\u0432\u0438\u0440\u0443\u0439\u0442\u0435 \u0441\u043e\u0431\u044b\u0442\u0438\u044f, \u043d\u0430\u0436\u0430\u0432 **Enable** \u0438 **Create project**. \n\n\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043f\u0440\u043e\u0435\u043a\u0442\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430\u043c \u043d\u0438\u0436\u0435 ([\u043f\u043e\u0434\u0440\u043e\u0431\u043d\u0435\u0435]({more_info_url})).", + "description": "\u0421\u043e\u0437\u0434\u0430\u0439\u0442\u0435 \u043f\u0440\u043e\u0435\u043a\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443, \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e **Google \u043f\u043e\u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u043e\u043f\u043b\u0430\u0442\u0443 \u0432 \u0440\u0430\u0437\u043c\u0435\u0440\u0435 5 \u0434\u043e\u043b\u043b\u0430\u0440\u043e\u0432 \u0421\u0428\u0410**.\n1. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u0432 [\u041a\u043e\u043d\u0441\u043e\u043b\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430\u043c]({device_access_console_url}) \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u043f\u0440\u043e\u0446\u0435\u0441\u0441 \u043e\u043f\u043b\u0430\u0442\u044b.\n2. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 **Create project**.\n3. \u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0435\u043a\u0442\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 **Next**.\n4. \u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0441\u0432\u043e\u0439 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043a\u043b\u0438\u0435\u043d\u0442\u0430 OAuth.\n5. \u0410\u043a\u0442\u0438\u0432\u0438\u0440\u0443\u0439\u0442\u0435 \u0441\u043e\u0431\u044b\u0442\u0438\u044f, \u043d\u0430\u0436\u0430\u0432 **Enable** \u0438 **Create project**. \n\n\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043f\u0440\u043e\u0435\u043a\u0442\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430\u043c \u043d\u0438\u0436\u0435 ([\u043f\u043e\u0434\u0440\u043e\u0431\u043d\u0435\u0435]({more_info_url})).", "title": "Nest: \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0435\u043a\u0442\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, "device_project_upgrade": { diff --git a/homeassistant/components/nest/translations/tr.json b/homeassistant/components/nest/translations/tr.json index 986412798dc12c78363e95e4875688a5058b57bd..4e52843ca5160b9196ed7c2c342c0de30068afb8 100644 --- a/homeassistant/components/nest/translations/tr.json +++ b/homeassistant/components/nest/translations/tr.json @@ -52,7 +52,7 @@ "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", + "description": "Kurulum i\u00e7in **Google'a 5 ABD dolar\u0131 tutar\u0131nda bir \u00fccret \u00f6denmesini 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": { diff --git a/homeassistant/components/nest/translations/zh-Hant.json b/homeassistant/components/nest/translations/zh-Hant.json index 77f271518a58d9a0b5c10abb511bd730aae6a858..a0ff9cab7f8d7cb091a5dd2eceda37c2c0d3da8e 100644 --- a/homeassistant/components/nest/translations/zh-Hant.json +++ b/homeassistant/components/nest/translations/zh-Hant.json @@ -52,7 +52,7 @@ "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", + "description": "\u5efa\u7acb Nest \u88dd\u7f6e\u5b58\u53d6\u5c08\u6848 **\u5c07\u6703\u9700\u8981\u652f\u4ed8 Google $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": { diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index eb0e93c4b3829b478f01384b9f91330e1cf36e0d..aa8728d548d05efe10de0e83cc21ecdc8c5a5098 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -271,7 +271,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if CONF_WEBHOOK_ID in entry.data: webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - await data[entry.entry_id][AUTH].async_dropwebhook() + try: + await data[entry.entry_id][AUTH].async_dropwebhook() + except pyatmo.ApiError: + _LOGGER.debug("No webhook to be dropped") _LOGGER.info("Unregister Netatmo webhook") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 400004ee4d1fea769001fd51d68294e27222c1d9..15b3e35ce054ea65c1302937ec21fb8fdbf8543e 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -315,7 +315,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): elif preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX): await self._room.async_therm_set(PRESET_MAP_NETATMO[preset_mode]) elif preset_mode in (PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY): - await self._room.async_therm_set(PRESET_MAP_NETATMO[preset_mode]) + await self._room.home.async_set_thermmode(PRESET_MAP_NETATMO[preset_mode]) else: _LOGGER.error("Preset mode '%s' not available", preset_mode) diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index a376e6ee1875fdb09087edb0b7a93fc18f66ff0e..1a322f8d8dbccc518d0af2a95c6e8fdd964ed3bc 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -176,8 +176,8 @@ class NetatmoDataHandler: @callback def async_force_update(self, signal_name: str) -> None: """Prioritize data retrieval for given data class entry.""" - # self.publisher[signal_name].next_scan = time() - # self._queue.rotate(-(self._queue.index(self.publisher[signal_name]))) + self.publisher[signal_name].next_scan = time() + self._queue.rotate(-(self._queue.index(self.publisher[signal_name]))) async def handle_event(self, event: dict) -> None: """Handle webhook events.""" @@ -252,7 +252,7 @@ class NetatmoDataHandler: self, signal_name: str, update_callback: CALLBACK_TYPE | None ) -> None: """Unsubscribe from publisher.""" - if update_callback in self.publisher[signal_name].subscriptions: + if update_callback not in self.publisher[signal_name].subscriptions: return self.publisher[signal_name].subscriptions.remove(update_callback) @@ -288,6 +288,9 @@ class NetatmoDataHandler: person.entity_id: person.pseudo for person in home.persons.values() } + await self.unsubscribe(WEATHER, None) + await self.unsubscribe(AIR_CARE, None) + def setup_air_care(self) -> None: """Set up home coach/air care modules.""" for module in self.account.modules.values(): diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index b3e352eb7d8683824370777fbe7e0d87e993cd58..e3bd8952b555be273b47841c105b7ef2a2cadfc3 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -193,17 +193,20 @@ class NetatmoLight(NetatmoBase, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn light on.""" - _LOGGER.debug("Turn light '%s' on", self.name) if ATTR_BRIGHTNESS in kwargs: await self._dimmer.async_set_brightness(kwargs[ATTR_BRIGHTNESS]) else: await self._dimmer.async_on() + self._attr_is_on = True + self.async_write_ha_state() + async def async_turn_off(self, **kwargs: Any) -> None: """Turn light off.""" - _LOGGER.debug("Turn light '%s' off", self.name) await self._dimmer.async_off() + self._attr_is_on = False + self.async_write_ha_state() @callback def async_update_callback(self) -> None: diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 1e3354f1c27c89932fa70412cbab611e0d552c74..e34156ff589d232298814174700ea0bdbbcad9f2 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -2,7 +2,7 @@ "domain": "netatmo", "name": "Netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo", - "requirements": ["pyatmo==7.1.1"], + "requirements": ["pyatmo==7.3.0"], "after_dependencies": ["cloud", "media_source"], "dependencies": ["application_credentials", "webhook"], "codeowners": ["@cgtobi"], @@ -11,11 +11,5 @@ "models": ["Healty Home Coach", "Netatmo Relay", "Presence", "Welcome"] }, "iot_class": "cloud_polling", - "loggers": ["pyatmo"], - "supported_brands": { - "legrand": "Legrand", - "bubendorff": "Bubendorff", - "smarther": "Smarther", - "bticino": "BTicino" - } + "loggers": ["pyatmo"] } diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/netatmo_entity_base.py index 081d06f5d4fd0cf0f6f5ae8cf5f275fe0d90776d..c434d370e277de60e9f6bbb355ad7d6950a4fe48 100644 --- a/homeassistant/components/netatmo/netatmo_entity_base.py +++ b/homeassistant/components/netatmo/netatmo_entity_base.py @@ -8,7 +8,6 @@ from pyatmo.modules.device_types import ( DeviceType as NetatmoDeviceType, ) -from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity @@ -20,6 +19,8 @@ from .data_handler import PUBLIC, NetatmoDataHandler class NetatmoBase(Entity): """Netatmo entity base class.""" + _attr_attribution = DEFAULT_ATTRIBUTION + def __init__(self, data_handler: NetatmoDataHandler) -> None: """Set up Netatmo entity base.""" self.data_handler = data_handler @@ -31,7 +32,7 @@ class NetatmoBase(Entity): self._config_url: str = "" self._attr_name = None self._attr_unique_id = None - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_extra_state_attributes = {} async def async_added_to_hass(self) -> None: """Entity created.""" @@ -62,9 +63,11 @@ class NetatmoBase(Entity): publisher["name"], signal_name, self.async_update_callback ) - for sub in self.data_handler.publisher[signal_name].subscriptions: - if sub is None: - await self.data_handler.unsubscribe(signal_name, None) + if any( + sub is None + for sub in self.data_handler.publisher[signal_name].subscriptions + ): + await self.data_handler.unsubscribe(signal_name, None) registry = dr.async_get(self.hass) if device := registry.async_get_device({(DOMAIN, self._id)}): diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 65ac610ef5d359eab07f9597b6b3ff0bb5ed51ef..82f6c95b69995af5183d66ac5b33679a72285b99 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -399,8 +399,7 @@ async def async_setup_entry( for device_id in entities.values(): device_registry.async_remove_device(device_id) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) async_dispatcher_connect( hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}", add_public_entities diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 338d073c2053180657c65bacd9c7a90e08c13408..a2e2e67db395f8ed283829692fb33bdede5670e9 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -77,7 +77,11 @@ class NetatmoSwitch(NetatmoBase, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the zone on.""" await self._switch.async_on() + self._attr_is_on = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the zone off.""" await self._switch.async_off() + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/translations/no.json b/homeassistant/components/netatmo/translations/no.json index dc751d3a4b5b088110206d21b65a454d30606879..cddfa3b3ffbd0bbac14e741a51ca15602e3d9ebb 100644 --- a/homeassistant/components/netatmo/translations/no.json +++ b/homeassistant/components/netatmo/translations/no.json @@ -4,7 +4,7 @@ "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})", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "create_entry": { diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index 8c51a3fd9a64070ece1cc767a91cc67942d1f81d..6606604ac90c9bbb0ece8f6b643acc60c6876dd7 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -1,7 +1,6 @@ """Support gathering system information of hosts which are running netdata.""" from __future__ import annotations -from datetime import timedelta import logging from netdata import Netdata @@ -22,12 +21,9 @@ from homeassistant.exceptions import PlatformNotReady 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 Throttle _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) - CONF_DATA_GROUP = "data_group" CONF_ELEMENT = "element" CONF_INVERT = "invert" @@ -223,7 +219,6 @@ class NetdataData: self.api = api self.available = True - @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): """Get the latest data from the Netdata REST API.""" diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index cd648990e05d60061c2b5431c58afacf79285592..5feff521efa21d23bc38176c84e6f044de60e451 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -39,8 +39,7 @@ async def async_setup_entry( new_entities.append(NetgearScannerEntity(coordinator, router, device)) tracked.add(mac) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) entry.async_on_unload(coordinator.async_add_listener(new_device_callback)) diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index c38142a3dc51445bc63f97e6c04b8531a4c1a6a3..dc857e1377a015eca49c1ebdfdded00f498ca78e 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -338,8 +338,7 @@ async def async_setup_entry( ) tracked.add(mac) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) entry.async_on_unload(coordinator.async_add_listener(new_device_callback)) diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index 6491ecf0abeefd33064a86b1cfafc7413cb0ba14..4318ae7598daeb363f556e4af54031a99dc37ee9 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -143,8 +143,7 @@ async def async_setup_entry( ) tracked.add(mac) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) entry.async_on_unload(coordinator.async_add_listener(new_device_callback)) diff --git a/homeassistant/components/network/websocket.py b/homeassistant/components/network/websocket.py index c19ed5e8fd79c2d2a126d23b14517a03a5fe4372..4c55585e112d1d56077a409e0ac77a4b00fa59bc 100644 --- a/homeassistant/components/network/websocket.py +++ b/homeassistant/components/network/websocket.py @@ -1,6 +1,8 @@ """The Network Configuration integration websocket commands.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components import websocket_api @@ -24,7 +26,7 @@ def async_register_websocket_commands(hass: HomeAssistant) -> None: async def websocket_network_adapters( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Return network preferences.""" network = await async_get_network(hass) @@ -48,7 +50,7 @@ async def websocket_network_adapters( async def websocket_network_adapters_configure( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Update network config.""" network = await async_get_network(hass) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 77280b1f503ca9c08dfa3c47b44f1b1d42a6f8c3..78576e06b8aff06308a325e43a018b33aef8df4c 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==2.0.4"], + "requirements": ["nexia==2.0.5"], "codeowners": ["@bdraco"], "documentation": "https://www.home-assistant.io/integrations/nexia", "config_flow": true, diff --git a/homeassistant/components/nexia/translations/nb.json b/homeassistant/components/nexia/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/nexia/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nexity/manifest.json b/homeassistant/components/nexity/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..31275f80eb439ed74cd2e1e58d57f2b954bf9a8d --- /dev/null +++ b/homeassistant/components/nexity/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "nexity", + "name": "Nexity Eugénie", + "integration_type": "virtual", + "supported_by": "overkiz" +} diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 48f0c3306322e267eb1c1fdd0a9e2fb1ed24a5d6..269bd96aa31b2670a2a4566b0cc6f0fd860fd13c 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -85,7 +85,6 @@ SENSORS = ( "nextcloud_server_php_upload_max_filesize", "nextcloud_database_type", "nextcloud_database_version", - "nextcloud_database_version", "nextcloud_activeUsers_last5minutes", "nextcloud_activeUsers_last1hour", "nextcloud_activeUsers_last24hours", @@ -101,6 +100,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: ncm = NextcloudMonitor(conf[CONF_URL], conf[CONF_USERNAME], conf[CONF_PASSWORD]) except NextcloudMonitorError: _LOGGER.error("Nextcloud setup failed - Check configuration") + return False hass.data[DOMAIN] = get_data_points(ncm.data) hass.data[DOMAIN]["instance"] = conf[CONF_URL] diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index 04c2e3575f1e46e1a6356832120494a8f6aaa283..2a68107079e87c11b824813f402b23da0ad4460d 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -7,5 +7,6 @@ "config_flow": true, "iot_class": "cloud_polling", "loggers": ["nextdns"], - "quality_scale": "platinum" + "quality_scale": "platinum", + "integration_type": "service" } diff --git a/homeassistant/components/nextdns/translations/nb.json b/homeassistant/components/nextdns/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/nextdns/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/nb.json b/homeassistant/components/nfandroidtv/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index b9921df4e1ef6c7feafa44383b3820fd975f8991..053d6db2a34cc4b6449b5cde0de9326b5209711b 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -1,6 +1,7 @@ """The Nibe Heat Pump integration.""" from __future__ import annotations +import asyncio from collections import defaultdict from collections.abc import Callable, Iterable from datetime import timedelta @@ -103,7 +104,7 @@ 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): coordinator: Coordinator = hass.data[DOMAIN].pop(entry.entry_id) - await coordinator.connection.stop() + await coordinator.async_shutdown() return unload_ok @@ -173,6 +174,7 @@ class Coordinator(ContextCoordinator[dict[int, Coil], int]): self.seed: dict[int, Coil] = {} self.connection = connection self.heatpump = heatpump + self.task: asyncio.Task | None = None heatpump.subscribe(heatpump.COIL_UPDATE_EVENT, self._on_coil_update) @@ -219,6 +221,13 @@ class Coordinator(ContextCoordinator[dict[int, Coil], int]): self.async_update_context_listeners([coil.address]) async def _async_update_data(self) -> dict[int, Coil]: + self.task = asyncio.current_task() + try: + return await self._async_update_data_internal() + finally: + self.task = None + + async def _async_update_data_internal(self) -> dict[int, Coil]: @retry( retry=retry_if_exception_type(CoilReadException), stop=stop_after_attempt(COIL_READ_RETRIES), @@ -249,6 +258,14 @@ class Coordinator(ContextCoordinator[dict[int, Coil], int]): return result + async def async_shutdown(self): + """Make sure a coordinator is shut down as well as it's connection.""" + if self.task: + self.task.cancel() + await asyncio.wait((self.task,)) + self._unschedule_refresh() + await self.connection.stop() + class CoilEntity(CoordinatorEntity[Coordinator]): """Base for coil based entities.""" diff --git a/homeassistant/components/nibe_heatpump/config_flow.py b/homeassistant/components/nibe_heatpump/config_flow.py index 412b44d69a1b9de08f4a9c409a4b3359d9cc9305..d68def046fd52868e821667a28a02950f1e9fc10 100644 --- a/homeassistant/components/nibe_heatpump/config_flow.py +++ b/homeassistant/components/nibe_heatpump/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import errno +from socket import gaierror from typing import Any from nibe.connection.nibegw import NibeGW @@ -13,8 +14,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_IP_ADDRESS, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import config_validation as cv -from homeassistant.util.network import is_ipv4_address +from homeassistant.helpers import selector from .const import ( CONF_CONNECTION_TYPE, @@ -27,13 +27,22 @@ from .const import ( LOGGER, ) +PORT_SELECTOR = vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, step=1, max=65535, mode=selector.NumberSelectorMode.BOX + ) + ), + vol.Coerce(int), +) + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_MODEL): vol.In(list(Model.__members__)), - vol.Required(CONF_IP_ADDRESS): str, - vol.Required(CONF_LISTENING_PORT): cv.port, - vol.Required(CONF_REMOTE_READ_PORT): cv.port, - vol.Required(CONF_REMOTE_WRITE_PORT): cv.port, + vol.Required(CONF_IP_ADDRESS): selector.TextSelector(), + vol.Required(CONF_LISTENING_PORT, default=9999): PORT_SELECTOR, + vol.Required(CONF_REMOTE_READ_PORT, default=9999): PORT_SELECTOR, + vol.Required(CONF_REMOTE_WRITE_PORT, default=10000): PORT_SELECTOR, } ) @@ -51,9 +60,6 @@ class FieldError(Exception): async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect.""" - if not is_ipv4_address(data[CONF_IP_ADDRESS]): - raise FieldError("Not a valid ipv4 address", CONF_IP_ADDRESS, "address") - heatpump = HeatPump(Model[data[CONF_MODEL]]) heatpump.initialize() @@ -79,6 +85,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, coil = await connection.read_coil(coil) word_swap = coil.value == "ON" coil = await connection.write_coil(coil) + except gaierror as exception: + raise FieldError(str(exception), "ip_address", "address") from exception except CoilNotFoundException as exception: raise FieldError( "Model selected doesn't seem to support expected coils", "base", "model" diff --git a/homeassistant/components/nibe_heatpump/strings.json b/homeassistant/components/nibe_heatpump/strings.json index 45e55b61083d2a685dcf3598a5683f75ee812751..08a049cb17af66994e4ab329c184fad48484be38 100644 --- a/homeassistant/components/nibe_heatpump/strings.json +++ b/homeassistant/components/nibe_heatpump/strings.json @@ -2,18 +2,25 @@ "config": { "step": { "user": { + "description": "Before attempting to configure the integration, verify that:\n - The NibeGW unit is connected to a heat pump.\n - The MODBUS40 accessory has been enabled in the heat pump configuration.\n - The pump has not gone into an alarm state about missing MODBUS40 accessory.", "data": { - "ip_address": "Remote IP address", + "ip_address": "Remote address", "remote_read_port": "Remote read port", "remote_write_port": "Remote write port", "listening_port": "Local listening port" + }, + "data_description": { + "ip_address": "The address of the NibeGW unit. The device should have been configured with a static address.", + "remote_read_port": "The port the NibeGW unit is listening for read requests on.", + "remote_write_port": "The port the NibeGW unit is listening for write requests on.", + "listening_port": "The local port on this system, that the NibeGW unit is configured to send data to." } } }, "error": { "write": "Error on write request to pump. Verify your `Remote write port` or `Remote IP address`.", "read": "Error on read request from pump. Verify your `Remote read port` or `Remote IP address`.", - "address": "Invalid remote IP address specified. Address must be a IPV4 address.", + "address": "Invalid remote address specified. Address must be an IP address or a resolvable hostname.", "address_in_use": "The selected listening port is already in use on this system.", "model": "The model selected doesn't seem to support modbus40", "unknown": "[%key:common::config_flow::error::unknown%]" diff --git a/homeassistant/components/nibe_heatpump/translations/ca.json b/homeassistant/components/nibe_heatpump/translations/ca.json index 95cc0f32841e4732dc92e70dfb9e18864cc1c6cc..d2924212386292002907a3ddbb561c524becec8c 100644 --- a/homeassistant/components/nibe_heatpump/translations/ca.json +++ b/homeassistant/components/nibe_heatpump/translations/ca.json @@ -4,7 +4,7 @@ "already_configured": "El dispositiu ja est\u00e0 configurat" }, "error": { - "address": "Adre\u00e7a IP remota inv\u00e0lida. L'adre\u00e7a ha de ser una adre\u00e7a IPV4.", + "address": "Adre\u00e7a remota inv\u00e0lida. L'adre\u00e7a ha de ser una adre\u00e7a IP o un nom d'amfitri\u00f3 resoluble.", "address_in_use": "El port d'escolta seleccionat ja est\u00e0 en \u00fas en aquest sistema.", "model": "El model seleccionat no sembla admetre modbus40", "read": "Error en la sol\u00b7licitud de lectura de la bomba. Verifica el port remot de lectura i/o l'adre\u00e7a IP remota.", @@ -14,11 +14,18 @@ "step": { "user": { "data": { - "ip_address": "Adre\u00e7a IP remota", + "ip_address": "Adre\u00e7a remota", "listening_port": "Port local d'escolta", "remote_read_port": "Port remot de lectura", "remote_write_port": "Port remot d'escriptura" - } + }, + "data_description": { + "ip_address": "Adre\u00e7a de la unitat NibeGW. El dispositiu hauria d'estar configurat amb una adre\u00e7a est\u00e0tica.", + "listening_port": "Port local d'aquest sistema al qual la unitat NibeGW est\u00e0 configurada per enviar-hi dades.", + "remote_read_port": "Port on la unitat NibeGW espera les sol\u00b7licituds de lectura.", + "remote_write_port": "Port on la unitat NibeGW espera les sol\u00b7licituds d'escriptura." + }, + "description": "Abans d'intentar configurar la integraci\u00f3, comprova que:\n - La unitat NibeGW est\u00e0 connectada a una bomba de calor.\n - S'ha activat l'accessori MODBUS40 a la configuraci\u00f3 de la bomba de calor.\n - La bomba no ha entrat en estat d'alarma per falta de l'accessori MODBUS40." } } } diff --git a/homeassistant/components/nibe_heatpump/translations/de.json b/homeassistant/components/nibe_heatpump/translations/de.json index 8eda1b68b8b781862bd8e3e4c40e1e3801e0423a..5cddee9d912d8451cc041b4efe816c44244209de 100644 --- a/homeassistant/components/nibe_heatpump/translations/de.json +++ b/homeassistant/components/nibe_heatpump/translations/de.json @@ -4,7 +4,7 @@ "already_configured": "Ger\u00e4t ist bereits konfiguriert" }, "error": { - "address": "Ung\u00fcltige Remote-IP-Adresse angegeben. Die Adresse muss eine IPV4-Adresse sein.", + "address": "Ung\u00fcltige Remote-Adresse angegeben. Die Adresse muss eine IP-Adresse oder ein aufl\u00f6sbarer Hostname sein.", "address_in_use": "Der ausgew\u00e4hlte Listening-Port wird auf diesem System bereits verwendet.", "model": "Das ausgew\u00e4hlte Modell scheint modbus40 nicht zu unterst\u00fctzen", "read": "Fehler bei Leseanforderung von Pumpe. \u00dcberpr\u00fcfe deinen \u201eRemote-Leseport\u201c oder \u201eRemote-IP-Adresse\u201c.", @@ -14,11 +14,18 @@ "step": { "user": { "data": { - "ip_address": "Remote-IP-Adresse", + "ip_address": "Remote-Adresse", "listening_port": "Lokaler Leseport", "remote_read_port": "Remote-Leseport", "remote_write_port": "Remote-Schreibport" - } + }, + "data_description": { + "ip_address": "Die Adresse des NibeGW-Ger\u00e4ts. Das Ger\u00e4t sollte mit einer statischen Adresse konfiguriert worden sein.", + "listening_port": "Der lokale Port auf diesem System, an den das NibeGW-Ger\u00e4t Daten senden soll.", + "remote_read_port": "Der Port, an dem das NibeGW-Ger\u00e4t auf Leseanfragen wartet.", + "remote_write_port": "Der Port, an dem das NibeGW-Ger\u00e4t auf Schreibanfragen wartet." + }, + "description": "Bevor du versuchst, die Integration zu konfigurieren, \u00fcberpr\u00fcfe folgendes:\n - Das NibeGW-Ger\u00e4t ist an eine W\u00e4rmepumpe angeschlossen.\n - Das MODBUS40-Zubeh\u00f6r wurde in der Konfiguration der W\u00e4rmepumpe aktiviert.\n - Die Pumpe ist nicht in einen Alarmzustand wegen fehlendem MODBUS40-Zubeh\u00f6r \u00fcbergegangen." } } } diff --git a/homeassistant/components/nibe_heatpump/translations/en.json b/homeassistant/components/nibe_heatpump/translations/en.json index 74dd8313e95ccd713c216ee1c7911e207c134f16..4c6e86720f1ed15826ab9c24ad8426ca6e50c5f7 100644 --- a/homeassistant/components/nibe_heatpump/translations/en.json +++ b/homeassistant/components/nibe_heatpump/translations/en.json @@ -4,7 +4,7 @@ "already_configured": "Device is already configured" }, "error": { - "address": "Invalid remote IP address specified. Address must be a IPV4 address.", + "address": "Invalid remote address specified. Address must be an IP address or a resolvable hostname.", "address_in_use": "The selected listening port is already in use on this system.", "model": "The model selected doesn't seem to support modbus40", "read": "Error on read request from pump. Verify your `Remote read port` or `Remote IP address`.", @@ -14,11 +14,18 @@ "step": { "user": { "data": { - "ip_address": "Remote IP address", + "ip_address": "Remote address", "listening_port": "Local listening port", "remote_read_port": "Remote read port", "remote_write_port": "Remote write port" - } + }, + "data_description": { + "ip_address": "The address of the NibeGW unit. The device should have been configured with a static address.", + "listening_port": "The local port on this system, that the NibeGW unit is configured to send data to.", + "remote_read_port": "The port the NibeGW unit is listening for read requests on.", + "remote_write_port": "The port the NibeGW unit is listening for write requests on." + }, + "description": "Before attempting to configure the integration, verify that:\n - The NibeGW unit is connected to a heat pump.\n - The MODBUS40 accessory has been enabled in the heat pump configuration.\n - The pump has not gone into an alarm state about missing MODBUS40 accessory." } } } diff --git a/homeassistant/components/nibe_heatpump/translations/es.json b/homeassistant/components/nibe_heatpump/translations/es.json index 60c228a28e7715facef4d5f96b1eff8c307c92db..0619471f538d57463221cf64a450d3f7d5a84408 100644 --- a/homeassistant/components/nibe_heatpump/translations/es.json +++ b/homeassistant/components/nibe_heatpump/translations/es.json @@ -4,7 +4,7 @@ "already_configured": "El dispositivo ya est\u00e1 configurado" }, "error": { - "address": "Se especific\u00f3 una direcci\u00f3n IP remota no v\u00e1lida. La direcci\u00f3n debe ser una direcci\u00f3n IPv4.", + "address": "Se especific\u00f3 una direcci\u00f3n remota no v\u00e1lida. La direcci\u00f3n debe ser una direcci\u00f3n IP o un nombre de host resoluble.", "address_in_use": "El puerto de escucha seleccionado ya est\u00e1 en uso en este sistema.", "model": "El modelo seleccionado no parece ser compatible con modbus40", "read": "Error en la solicitud de lectura de la bomba. Verifica tu `Puerto de lectura remoto` o `Direcci\u00f3n IP remota`.", @@ -14,11 +14,18 @@ "step": { "user": { "data": { - "ip_address": "Direcci\u00f3n IP remota", + "ip_address": "Direcci\u00f3n remota", "listening_port": "Puerto de escucha local", "remote_read_port": "Puerto de lectura remoto", "remote_write_port": "Puerto de escritura remoto" - } + }, + "data_description": { + "ip_address": "La direcci\u00f3n de la unidad NibeGW. El dispositivo deber\u00eda haber sido configurado con una direcci\u00f3n est\u00e1tica.", + "listening_port": "El puerto local en este sistema, al que la unidad NibeGW est\u00e1 configurada para enviar datos.", + "remote_read_port": "El puerto en el que la unidad NibeGW est\u00e1 escuchando las peticiones de lectura.", + "remote_write_port": "El puerto en el que la unidad NibeGW est\u00e1 escuchando peticiones de escritura." + }, + "description": "Antes de intentar configurar la integraci\u00f3n, verifica que:\n- La unidad NibeGW est\u00e1 conectada a una bomba de calor.\n- Se ha habilitado el accesorio MODBUS40 en la configuraci\u00f3n de la bomba de calor.\n- La bomba no ha entrado en estado de alarma por falta del accesorio MODBUS40." } } } diff --git a/homeassistant/components/nibe_heatpump/translations/et.json b/homeassistant/components/nibe_heatpump/translations/et.json new file mode 100644 index 0000000000000000000000000000000000000000..223d0f22c1a86db03f6b473fdc0f80a14eee98cb --- /dev/null +++ b/homeassistant/components/nibe_heatpump/translations/et.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "address": "M\u00e4\u00e4ratud vale kaugaadress. Aadress peab olema IP-aadress v\u00f5i lahendatav hostinimi.", + "address_in_use": "Valitud kuulamisport on selles s\u00fcsteemis juba kasutusel.", + "model": "Valitud mudel ei n\u00e4i toetavat modbus40.", + "read": "Viga pumba lugemistaotlusel. Kinnitage oma \"Kaugloetav port\" v\u00f5i \"Kaug-IP-aadress\".", + "unknown": "Ootamatu t\u00f5rge", + "write": "Viga pumba kirjutamise taotlusel. Kontrollige oma `kaugkirjutusport` v\u00f5i `kaug-IP-aadress`." + }, + "step": { + "user": { + "data": { + "ip_address": "Kaug-IP-aadress", + "listening_port": "Kohalik kuulamisport", + "remote_read_port": "Kauglugemise port", + "remote_write_port": "Kaugkirjutusport" + }, + "data_description": { + "ip_address": "NibeGW-\u00fcksuse aadress. Seade peaks olema seadistatud staatilise aadressiga.", + "listening_port": "Selle s\u00fcsteemi kohalik port kuhu NibeGW seade on seadistatud andmeid saatma.", + "remote_read_port": "Port, mille kaudu NibeGW-\u00fcksus loeb lugemisp\u00e4ringuid.", + "remote_write_port": "Port, mille kaudu NibeGW-\u00fcksus kuulab kirjutamisp\u00e4ringuid." + }, + "description": "Enne sidumise seadistamist veendu, et:\n - NibeGW seade on \u00fchendatud soojuspumbaga.\n - MODBUS40 tarvik on soojuspumba konfiguratsioonis lubatud.\n - Pump ei ole MODBUS40 lisaseadme puudumise t\u00f5ttu h\u00e4ireolekusse l\u00e4inud." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nibe_heatpump/translations/fr.json b/homeassistant/components/nibe_heatpump/translations/fr.json index dc28a729aea0269d2595b0a0d9e3e063f33017cd..6c12361adc5f0a1b0073270f783d2cecafe7cf6b 100644 --- a/homeassistant/components/nibe_heatpump/translations/fr.json +++ b/homeassistant/components/nibe_heatpump/translations/fr.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "ip_address": "Adresse IP distante", + "ip_address": "Adresse distante", "listening_port": "Port d'\u00e9coute local", "remote_read_port": "Port de lecture distant", "remote_write_port": "Port d'\u00e9criture distant" diff --git a/homeassistant/components/nibe_heatpump/translations/hu.json b/homeassistant/components/nibe_heatpump/translations/hu.json index 4fcff29a560cb1f746790fbed1d6e7da316490ad..1dc8ea121796d15d3f7808e8dd26f4b5646db399 100644 --- a/homeassistant/components/nibe_heatpump/translations/hu.json +++ b/homeassistant/components/nibe_heatpump/translations/hu.json @@ -4,7 +4,7 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" }, "error": { - "address": "\u00c9rv\u00e9nytelen t\u00e1voli IP-c\u00edm van megadva. A c\u00edmnek IPV4-c\u00edmnek kell lennie.", + "address": "\u00c9rv\u00e9nytelen t\u00e1voli c\u00edm van megadva. A c\u00edmnek IP-c\u00edmnek vagy feloldhat\u00f3 g\u00e9pn\u00e9vnek kell lennie.", "address_in_use": "A kiv\u00e1lasztott port m\u00e1r haszn\u00e1latban van ezen a rendszeren.", "model": "\u00dagy t\u0171nik, hogy a kiv\u00e1lasztott modell nem t\u00e1mogatja a modbus40-et", "read": "Hiba a szivatty\u00fa olvas\u00e1si k\u00e9r\u00e9s\u00e9n\u00e9l. Ellen\u0151rizze a \"T\u00e1voli olvas\u00e1si portot\" vagy a \"T\u00e1voli IP-c\u00edmet\".", @@ -18,7 +18,14 @@ "listening_port": "Helyi port", "remote_read_port": "T\u00e1voli olvas\u00e1si port", "remote_write_port": "T\u00e1voli \u00edr\u00e1si port" - } + }, + "data_description": { + "ip_address": "A NibeGW egys\u00e9g c\u00edme. A k\u00e9sz\u00fcl\u00e9ket statikus c\u00edmmel kell konfigur\u00e1lni.", + "listening_port": "A rendszer azon helyi portja, amelyre a NibeGW egys\u00e9g az adatok k\u00fcld\u00e9s\u00e9re van konfigur\u00e1lva.", + "remote_read_port": "A port, amelyen a NibeGW egys\u00e9g olvas\u00e1si k\u00e9r\u00e9seket fogad.", + "remote_write_port": "A port, amelyen a NibeGW egys\u00e9g \u00edr\u00e1si k\u00e9r\u00e9seket fogad." + }, + "description": "Miel\u0151tt megpr\u00f3b\u00e1ln\u00e1 konfigur\u00e1lni az integr\u00e1ci\u00f3t, ellen\u0151rizze, hogy:\n - A NibeGW egys\u00e9g h\u0151szivatty\u00fahoz van csatlakoztatva.\n - A MODBUS40 kieg\u00e9sz\u00edt\u0151 enged\u00e9lyezve van a h\u0151szivatty\u00fa konfigur\u00e1ci\u00f3j\u00e1ban.\n - A szivatty\u00fa nem l\u00e9pett riaszt\u00e1si \u00e1llapotba a MODBUS40 tartoz\u00e9k hi\u00e1nya miatt." } } } diff --git a/homeassistant/components/nibe_heatpump/translations/ja.json b/homeassistant/components/nibe_heatpump/translations/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..6ca4ad37a817bdfb49d29a83fcfe21d801318317 --- /dev/null +++ b/homeassistant/components/nibe_heatpump/translations/ja.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "ip_address": "\u30ea\u30e2\u30fc\u30c8IP\u30a2\u30c9\u30ec\u30b9", + "listening_port": "\u30ed\u30fc\u30ab\u30eb\u30ea\u30b9\u30cb\u30f3\u30b0\u30dd\u30fc\u30c8", + "remote_read_port": "\u30ea\u30e2\u30fc\u30c8\u8aad\u307f\u53d6\u308a\u30dd\u30fc\u30c8", + "remote_write_port": "\u30ea\u30e2\u30fc\u30c8\u66f8\u304d\u8fbc\u307f\u30dd\u30fc\u30c8" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nibe_heatpump/translations/nb.json b/homeassistant/components/nibe_heatpump/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..2e302a80d318aae7a34c4df37fdbc597eebc65c7 --- /dev/null +++ b/homeassistant/components/nibe_heatpump/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nibe_heatpump/translations/no.json b/homeassistant/components/nibe_heatpump/translations/no.json index a9c4c41993d3063a7ebcafcb48e316f084fb9739..b0a8f6017764357848cf40a729fef60989cd1dd9 100644 --- a/homeassistant/components/nibe_heatpump/translations/no.json +++ b/homeassistant/components/nibe_heatpump/translations/no.json @@ -4,7 +4,7 @@ "already_configured": "Enheten er allerede konfigurert" }, "error": { - "address": "Ugyldig ekstern IP-adresse er angitt. Adressen m\u00e5 v\u00e6re en IPV4-adresse.", + "address": "Ugyldig ekstern adresse er angitt. Adressen m\u00e5 v\u00e6re en IP-adresse eller et vertsnavn som kan l\u00f8ses.", "address_in_use": "Den valgte lytteporten er allerede i bruk p\u00e5 dette systemet.", "model": "Den valgte modellen ser ikke ut til \u00e5 st\u00f8tte modbus40", "read": "Feil ved leseforesp\u00f8rsel fra pumpe. Bekreft din \"Ekstern leseport\" eller \"Ekstern IP-adresse\".", @@ -14,11 +14,18 @@ "step": { "user": { "data": { - "ip_address": "Ekstern IP-adresse", + "ip_address": "Ekstern adresse", "listening_port": "Lokal lytteport", "remote_read_port": "Ekstern leseport", "remote_write_port": "Ekstern skriveport" - } + }, + "data_description": { + "ip_address": "Adressen til NibeGW-enheten. Enheten skal ha blitt konfigurert med en statisk adresse.", + "listening_port": "Den lokale porten p\u00e5 dette systemet, som NibeGW-enheten er konfigurert til \u00e5 sende data til.", + "remote_read_port": "Porten NibeGW-enheten lytter etter leseforesp\u00f8rsler p\u00e5.", + "remote_write_port": "Porten NibeGW-enheten lytter etter skriveforesp\u00f8rsler p\u00e5." + }, + "description": "F\u00f8r du pr\u00f8ver \u00e5 konfigurere integrasjonen, kontroller at:\n - NibeGW-enheten er koblet til en varmepumpe.\n - MODBUS40-tilbeh\u00f8ret er aktivert i varmepumpekonfigurasjonen.\n - Pumpen har ikke g\u00e5tt i alarmtilstand om manglende MODBUS40-tilbeh\u00f8r." } } } diff --git a/homeassistant/components/nibe_heatpump/translations/pl.json b/homeassistant/components/nibe_heatpump/translations/pl.json index 7a179ad7326d177a46f0107682ed6621455597ca..8298b41c64c9b2ffa4b2394b3ee01260ea8d10fe 100644 --- a/homeassistant/components/nibe_heatpump/translations/pl.json +++ b/homeassistant/components/nibe_heatpump/translations/pl.json @@ -4,7 +4,7 @@ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" }, "error": { - "address": "Podano nieprawid\u0142owy zdalny adres IP. Adres musi by\u0107 adresem IPV4.", + "address": "Podano nieprawid\u0142owy zdalny adres IP. Adres musi by\u0107 adresem IP lub rozpoznawaln\u0105 nazw\u0105 hosta.", "address_in_use": "Wybrany port nas\u0142uchiwania jest ju\u017c u\u017cywany w tym systemie.", "model": "Wybrany model nie obs\u0142uguje modbus40", "read": "B\u0142\u0105d przy \u017c\u0105daniu odczytu z pompy. Sprawd\u017a \u201eZdalny port odczytu\u201d lub \u201eZdalny adres IP\u201d.", @@ -18,7 +18,14 @@ "listening_port": "Lokalny port nas\u0142uchiwania", "remote_read_port": "Zdalny port odczytu", "remote_write_port": "Zdalny port zapisu" - } + }, + "data_description": { + "ip_address": "Adres urz\u0105dzenia NibeGW. Urz\u0105dzenie powinno by\u0107 skonfigurowane z adresem statycznym.", + "listening_port": "Port lokalny w tym systemie, do kt\u00f3rego urz\u0105dzenie NibeGW jest skonfigurowane do wysy\u0142ania danych.", + "remote_read_port": "Port, na kt\u00f3rym urz\u0105dzenie NibeGW nas\u0142uchuje \u017c\u0105da\u0144 odczytu.", + "remote_write_port": "Port, na kt\u00f3rym urz\u0105dzenie NibeGW nas\u0142uchuje \u017c\u0105da\u0144 zapisu." + }, + "description": "Przed przyst\u0105pieniem do konfiguracji integracji sprawd\u017a, czy:\n - Urz\u0105dzenie NibeGW jest pod\u0142\u0105czona do pompy ciep\u0142a.\n - Akcesorium MODBUS40 zosta\u0142o w\u0142\u0105czone w konfiguracji pompy ciep\u0142a.\n - Pompa nie wesz\u0142a w stan alarmowy z powodu braku akcesorium MODBUS40." } } } diff --git a/homeassistant/components/nibe_heatpump/translations/pt-BR.json b/homeassistant/components/nibe_heatpump/translations/pt-BR.json index 127f6c6010b2d9c8d76894e7bb4966ae9f4e1ee0..9f99984603641a312a3c711e59bfd9e82df7b975 100644 --- a/homeassistant/components/nibe_heatpump/translations/pt-BR.json +++ b/homeassistant/components/nibe_heatpump/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": { - "address": "Endere\u00e7o IP remoto inv\u00e1lido especificado. O endere\u00e7o deve ser um endere\u00e7o IPV4.", + "address": "Endere\u00e7o remoto inv\u00e1lido especificado. O endere\u00e7o deve ser um endere\u00e7o IP ou um nome de host resolv\u00edvel.", "address_in_use": "A porta de escuta selecionada j\u00e1 est\u00e1 em uso neste sistema.", "model": "O modelo selecionado parece n\u00e3o suportar modbus40", "read": "Erro na solicita\u00e7\u00e3o de leitura da bomba. Verifique sua `Porta de leitura remota` ou `Endere\u00e7o IP remoto`.", @@ -18,7 +18,14 @@ "listening_port": "Porta de escuta local", "remote_read_port": "Porta de leitura remota", "remote_write_port": "Porta de grava\u00e7\u00e3o remota" - } + }, + "data_description": { + "ip_address": "O endere\u00e7o da unidade NibeGW. O dispositivo deve ter sido configurado com um endere\u00e7o est\u00e1tico.", + "listening_port": "A porta local neste sistema para a qual a unidade NibeGW est\u00e1 configurada para enviar dados.", + "remote_read_port": "A porta na qual a unidade NibeGW est\u00e1 escutando solicita\u00e7\u00f5es de leitura.", + "remote_write_port": "A porta na qual a unidade NibeGW est\u00e1 escutando solicita\u00e7\u00f5es de grava\u00e7\u00e3o." + }, + "description": "Antes de tentar configurar a integra\u00e7\u00e3o, verifique se:\n - A unidade NibeGW est\u00e1 conectada a uma bomba de calor.\n - O acess\u00f3rio MODBUS40 foi habilitado na configura\u00e7\u00e3o da bomba de calor.\n - A bomba n\u00e3o entrou em estado de alarme por falta de acess\u00f3rio MODBUS40." } } } diff --git a/homeassistant/components/nibe_heatpump/translations/sv.json b/homeassistant/components/nibe_heatpump/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..4e0c9cdd7ca5160533f24f5dc9748edbea89d7dc --- /dev/null +++ b/homeassistant/components/nibe_heatpump/translations/sv.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "address": "Ogiltig fj\u00e4rr-IP-adress har angetts. Adressen m\u00e5ste vara en IPv4-adress.", + "address_in_use": "Den valda lyssningsporten anv\u00e4nds redan p\u00e5 detta system.", + "model": "Den valda modellen verkar inte st\u00f6dja modbus40", + "read": "Fel p\u00e5 l\u00e4sf\u00f6rfr\u00e5gan fr\u00e5n pumpen. Verifiera din \"Fj\u00e4rrl\u00e4sningsport\" eller \"Fj\u00e4rr-IP-adress\".", + "unknown": "Ov\u00e4ntat fel", + "write": "Fel vid skrivbeg\u00e4ran till pumpen. Verifiera din `Fj\u00e4rrskrivport` eller `Fj\u00e4rr-IP-adress`." + }, + "step": { + "user": { + "data": { + "ip_address": "Fj\u00e4rr IP-adress", + "listening_port": "Lokal lyssningsport", + "remote_read_port": "Port f\u00f6r fj\u00e4rravl\u00e4sning", + "remote_write_port": "Port f\u00f6r fj\u00e4rrskrivning" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nibe_heatpump/translations/tr.json b/homeassistant/components/nibe_heatpump/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..3e7c744ca31824f1c3048e595c06b4889b1466f6 --- /dev/null +++ b/homeassistant/components/nibe_heatpump/translations/tr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "address": "Ge\u00e7ersiz uzak IP adresi belirtildi. Adres bir IPV4 adresi olmal\u0131d\u0131r.", + "address_in_use": "Se\u00e7ilen dinleme ba\u011flant\u0131 noktas\u0131 bu sistemde zaten kullan\u0131l\u0131yor.", + "model": "Se\u00e7ilen model modbus40'\u0131 desteklemiyor gibi g\u00f6r\u00fcn\u00fcyor", + "read": "Pompadan okuma iste\u011finde hata. 'Uzaktan okuma ba\u011flant\u0131 noktas\u0131' veya 'Uzak IP adresinizi' do\u011frulay\u0131n.", + "unknown": "Beklenmeyen hata", + "write": "Pompaya yazma iste\u011finde hata. \"Uzak yazma ba\u011flant\u0131 noktas\u0131\" veya \"Uzak IP adresi\"nizi do\u011frulay\u0131n." + }, + "step": { + "user": { + "data": { + "ip_address": "Uzak IP adresi", + "listening_port": "Yerel dinleme ba\u011flant\u0131 noktas\u0131", + "remote_read_port": "Uzaktan okuma ba\u011flant\u0131 noktas\u0131", + "remote_write_port": "Uzaktan yazma ba\u011flant\u0131 noktas\u0131" + }, + "data_description": { + "remote_write_port": "NibeGW biriminin yazma isteklerini dinledi\u011fi ba\u011flant\u0131 noktas\u0131." + }, + "description": "Entegrasyonu yap\u0131land\u0131rmaya \u00e7al\u0131\u015fmadan \u00f6nce \u015funlar\u0131 do\u011frulay\u0131n:\n - NibeGW \u00fcnitesi bir \u0131s\u0131 pompas\u0131na ba\u011fl\u0131d\u0131r.\n - Is\u0131 pompas\u0131 konfig\u00fcrasyonunda MODBUS40 aksesuar\u0131 etkinle\u015ftirildi.\n - Pompa, eksik MODBUS40 aksesuar\u0131 ile ilgili alarm durumuna ge\u00e7medi." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nibe_heatpump/translations/zh-Hans.json b/homeassistant/components/nibe_heatpump/translations/zh-Hans.json new file mode 100644 index 0000000000000000000000000000000000000000..527e3717c4aa08df0ec15615e6045ca3e9a7d4f3 --- /dev/null +++ b/homeassistant/components/nibe_heatpump/translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "ip_address": "\u8fdc\u7a0bIP\u5730\u5740", + "listening_port": "\u672c\u5730\u76d1\u542c\u7aef\u53e3", + "remote_read_port": "\u8fdc\u7a0b\u8bfb\u53d6\u7aef\u53e3", + "remote_write_port": "\u8fdc\u7a0b\u5199\u5165\u7aef\u53e3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/nb.json b/homeassistant/components/nightscout/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/nightscout/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 17e0280ca50c86f8451406847cf5f289320ab4d9..f03a2c765cce88ebff55ac84bc9c235c077ae217 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -1,7 +1,7 @@ """The Nina integration.""" from __future__ import annotations -from typing import Any +from dataclasses import dataclass from async_timeout import timeout from pynina import ApiError, Nina @@ -12,21 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - _LOGGER, - ATTR_DESCRIPTION, - ATTR_EXPIRES, - ATTR_HEADLINE, - ATTR_ID, - ATTR_SENDER, - ATTR_SENT, - ATTR_SEVERITY, - ATTR_START, - CONF_FILTER_CORONA, - CONF_REGIONS, - DOMAIN, - SCAN_INTERVAL, -) +from .const import _LOGGER, CONF_FILTER_CORONA, CONF_REGIONS, DOMAIN, SCAN_INTERVAL PLATFORMS: list[str] = [Platform.BINARY_SENSOR] @@ -61,7 +47,24 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non await hass.config_entries.async_reload(entry.entry_id) -class NINADataUpdateCoordinator(DataUpdateCoordinator): +@dataclass +class NinaWarningData: + """Class to hold the warning data.""" + + id: str + headline: str + description: str + sender: str + severity: str + sent: str + start: str + expires: str + is_valid: bool + + +class NINADataUpdateCoordinator( + DataUpdateCoordinator[dict[str, list[NinaWarningData]]] +): """Class to manage fetching NINA data API.""" def __init__( @@ -70,7 +73,6 @@ class NINADataUpdateCoordinator(DataUpdateCoordinator): """Initialize.""" self._regions: dict[str, str] = regions self._nina: Nina = Nina(async_get_clientsession(hass)) - self.warnings: dict[str, Any] = {} self.corona_filter: bool = corona_filter for region in regions: @@ -78,7 +80,7 @@ class NINADataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> dict[str, list[NinaWarningData]]: """Update data.""" async with timeout(10): try: @@ -87,29 +89,30 @@ class NINADataUpdateCoordinator(DataUpdateCoordinator): raise UpdateFailed(err) from err return self._parse_data() - def _parse_data(self) -> dict[str, Any]: + def _parse_data(self) -> dict[str, list[NinaWarningData]]: """Parse warning data.""" - return_data: dict[str, Any] = {} + return_data: dict[str, list[NinaWarningData]] = {} for region_id, raw_warnings in self._nina.warnings.items(): - warnings_for_regions: list[Any] = [] + warnings_for_regions: list[NinaWarningData] = [] for raw_warn in raw_warnings: if "corona" in raw_warn.headline.lower() and self.corona_filter: continue - warn_obj: dict[str, Any] = { - ATTR_ID: raw_warn.id, - ATTR_HEADLINE: raw_warn.headline, - ATTR_DESCRIPTION: raw_warn.description, - ATTR_SENDER: raw_warn.sender, - ATTR_SEVERITY: raw_warn.severity, - ATTR_SENT: raw_warn.sent or "", - ATTR_START: raw_warn.start or "", - ATTR_EXPIRES: raw_warn.expires or "", - } - warnings_for_regions.append(warn_obj) + warning_data: NinaWarningData = NinaWarningData( + raw_warn.id, + raw_warn.headline, + raw_warn.description, + raw_warn.sender, + raw_warn.severity, + raw_warn.sent or "", + raw_warn.start or "", + raw_warn.expires or "", + raw_warn.isValid(), + ) + warnings_for_regions.append(warning_data) return_data[region_id] = warnings_for_regions diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 29f985df618799ff95cd8cb5e17cde47d5f844fa..76280ab159eec49a6930d1d7ba6ddc4db00a3238 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -72,25 +72,28 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti @property def is_on(self) -> bool: """Return the state of the sensor.""" - return len(self.coordinator.data[self._region]) > self._warning_index + if not len(self.coordinator.data[self._region]) > self._warning_index: + return False + + data = self.coordinator.data[self._region][self._warning_index] + + return data.is_valid @property def extra_state_attributes(self) -> dict[str, Any]: """Return extra attributes of the sensor.""" - if ( - not len(self.coordinator.data[self._region]) > self._warning_index - ) or not self.is_on: + if not self.is_on: return {} - data: dict[str, Any] = self.coordinator.data[self._region][self._warning_index] + data = self.coordinator.data[self._region][self._warning_index] return { - ATTR_HEADLINE: data[ATTR_HEADLINE], - ATTR_DESCRIPTION: data[ATTR_DESCRIPTION], - ATTR_SENDER: data[ATTR_SENDER], - ATTR_SEVERITY: data[ATTR_SEVERITY], - ATTR_ID: data[ATTR_ID], - ATTR_SENT: data[ATTR_SENT], - ATTR_START: data[ATTR_START], - ATTR_EXPIRES: data[ATTR_EXPIRES], + ATTR_HEADLINE: data.headline, + ATTR_DESCRIPTION: data.description, + ATTR_SENDER: data.sender, + ATTR_SEVERITY: data.severity, + ATTR_ID: data.id, + ATTR_SENT: data.sent, + ATTR_START: data.start, + ATTR_EXPIRES: data.expires, } diff --git a/homeassistant/components/nina/translations/bg.json b/homeassistant/components/nina/translations/bg.json index be3ffecd2842034f31477db0b7263037ea8bcf27..4fdba83979a23fe96cc075fa527723cb4823fd7f 100644 --- a/homeassistant/components/nina/translations/bg.json +++ b/homeassistant/components/nina/translations/bg.json @@ -9,6 +9,10 @@ } }, "options": { + "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": { "init": { "title": "\u041e\u043f\u0446\u0438\u0438" diff --git a/homeassistant/components/nina/translations/nb.json b/homeassistant/components/nina/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..42a62fb5164006152197f8fcff5fcb1863167535 --- /dev/null +++ b/homeassistant/components/nina/translations/nb.json @@ -0,0 +1,12 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + }, + "options": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py index 64847e3fa5c7f27545beeaf63578e9edf8b4098a..c92ed2300a498d3fc25d6bd4431e9fa69ffb7db9 100644 --- a/homeassistant/components/nissan_leaf/sensor.py +++ b/homeassistant/components/nissan_leaf/sensor.py @@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_conversion import DistanceConverter +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import LeafEntity from .const import ( @@ -122,15 +123,15 @@ class LeafRangeSensor(LeafEntity, SensorEntity): if ret is None: return None - if not self.car.hass.config.units.is_metric or self.car.force_miles: - ret = IMPERIAL_SYSTEM.length(ret, METRIC_SYSTEM.length_unit) + if self.car.hass.config.units is US_CUSTOMARY_SYSTEM or self.car.force_miles: + ret = DistanceConverter.convert(ret, LENGTH_KILOMETERS, LENGTH_MILES) return round(ret) @property def native_unit_of_measurement(self) -> str: """Battery range unit.""" - if not self.car.hass.config.units.is_metric or self.car.force_miles: + if self.car.hass.config.units is US_CUSTOMARY_SYSTEM or self.car.force_miles: return LENGTH_MILES return LENGTH_KILOMETERS diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 56fa0cd4a8da9d61379be265657f7bfda09eb5cd..51d2ae3c0828e26062aa70631609403772da144a 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, @@ -100,6 +99,8 @@ def setup_platform( class NMBSLiveBoard(SensorEntity): """Get the next train from a station's liveboard.""" + _attr_attribution = "https://api.irail.be/" + def __init__(self, api_client, live_station, station_from, station_to): """Initialize the sensor for getting liveboard data.""" self._station = live_station @@ -149,7 +150,6 @@ class NMBSLiveBoard(SensorEntity): "extra_train": int(self._attrs["isExtra"]) > 0, "vehicle_id": self._attrs["vehicle"], "monitored_station": self._station, - ATTR_ATTRIBUTION: "https://api.irail.be/", } if delay > 0: @@ -176,6 +176,7 @@ class NMBSLiveBoard(SensorEntity): class NMBSSensor(SensorEntity): """Get the the total travel time for a given connection.""" + _attr_attribution = "https://api.irail.be/" _attr_native_unit_of_measurement = TIME_MINUTES def __init__( @@ -223,7 +224,6 @@ class NMBSSensor(SensorEntity): "platform_arriving": self._attrs["arrival"]["platform"], "platform_departing": self._attrs["departure"]["platform"], "vehicle_id": self._attrs["departure"]["vehicle"], - ATTR_ATTRIBUTION: "https://api.irail.be/", } if canceled != 1: diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index 49635973cf82a5da26844fcd96a310b5678885a9..7f3260c7635abced4d2643ad1620494d6b5faf2c 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -9,23 +9,18 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_NAME, - CONF_TIME_ZONE, - CONF_UNIT_SYSTEM, -) +from homeassistant.const import CONF_NAME, CONF_TIME_ZONE, CONF_UNIT_SYSTEM 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.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.unit_system import METRIC_SYSTEM _LOGGER = logging.getLogger(__name__) CONF_STATION_ID = "station_id" -DEFAULT_ATTRIBUTION = "Data provided by NOAA" DEFAULT_NAME = "NOAA Tides" DEFAULT_TIMEZONE = "lst_ldt" @@ -57,7 +52,7 @@ def setup_platform( if CONF_UNIT_SYSTEM in config: unit_system = config[CONF_UNIT_SYSTEM] - elif hass.config.units.is_metric: + elif hass.config.units is METRIC_SYSTEM: unit_system = UNIT_SYSTEMS[1] else: unit_system = UNIT_SYSTEMS[0] @@ -84,6 +79,8 @@ def setup_platform( class NOAATidesAndCurrentsSensor(SensorEntity): """Representation of a NOAA Tides and Currents sensor.""" + _attr_attribution = "Data provided by NOAA" + def __init__(self, name, station_id, timezone, unit_system, station): """Initialize the sensor.""" self._name = name @@ -101,7 +98,7 @@ class NOAATidesAndCurrentsSensor(SensorEntity): @property def extra_state_attributes(self): """Return the state attributes of this device.""" - attr = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + attr = {} if self.data is None: return attr if self.data["hi_lo"][1] == "H": diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 7db9eb96f7e6a440d9e6e536872b484f29429744..d828fb78b783fbc94fa641d8831d182b14cd49e9 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -1,8 +1,6 @@ """The Nobø Ecohub integration.""" from __future__ import annotations -import logging - from pynobo import nobo from homeassistant.config_entries import ConfigEntry @@ -25,9 +23,7 @@ from .const import ( NOBO_MANUFACTURER, ) -PLATFORMS = [Platform.CLIMATE] - -_LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -37,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: discover = entry.data[CONF_AUTO_DISCOVERED] ip_address = None if discover else entry.data[CONF_IP_ADDRESS] hub = nobo(serial=serial, ip=ip_address, discover=discover, synchronous=False) - await hub.start() + await hub.connect() hass.data.setdefault(DOMAIN, {}) @@ -65,6 +61,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(options_update_listener)) + await hub.start() + return True diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index ba38e0b153014e4f71f0cf713b50f4302ffc997a..d138788fba075601ce51e0addf75ad3b1674e86f 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -69,10 +69,7 @@ async def async_setup_entry( ) # Add zones as entities - async_add_entities( - [NoboZone(zone_id, hub, override_type) for zone_id in hub.zones], - True, - ) + async_add_entities(NoboZone(zone_id, hub, override_type) for zone_id in hub.zones) class NoboZone(ClimateEntity): @@ -107,6 +104,7 @@ class NoboZone(ClimateEntity): ATTR_VIA_DEVICE: (DOMAIN, hub.hub_info[ATTR_SERIAL]), ATTR_SUGGESTED_AREA: hub.zones[zone_id][ATTR_NAME], } + self._read_state() async def async_added_to_hass(self) -> None: """Register callback from hub.""" diff --git a/homeassistant/components/nobo_hub/const.py b/homeassistant/components/nobo_hub/const.py index 320c2f43c07863ae18347e2c9c27b85d3f621bea..ff0f25cfec3bb77e7fc0d4fe9181c84772be53f2 100644 --- a/homeassistant/components/nobo_hub/const.py +++ b/homeassistant/components/nobo_hub/const.py @@ -17,3 +17,4 @@ ATTR_TEMP_ECO_C = "temp_eco_c" ATTR_OVERRIDE_ALLOWED = "override_allowed" ATTR_TARGET_TYPE = "target_type" ATTR_TARGET_ID = "target_id" +ATTR_ZONE_ID = "zone_id" diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json index 14e10a1ffaf22a2ecea934ea4eb2b89005f2e7fa..0df92c4c5aed8c62dc9b286db4fc846e08085bac 100644 --- a/homeassistant/components/nobo_hub/manifest.json +++ b/homeassistant/components/nobo_hub/manifest.json @@ -3,7 +3,7 @@ "name": "Nob\u00f8 Ecohub", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nobo_hub", - "requirements": ["pynobo==1.4.0"], + "requirements": ["pynobo==1.6.0"], "codeowners": ["@echoromeo", "@oyvindwe"], "iot_class": "local_push" } diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..fe33c6ee83ec58e5b71d60819ceabfec25506f19 --- /dev/null +++ b/homeassistant/components/nobo_hub/sensor.py @@ -0,0 +1,95 @@ +"""Python Control of Nobø Hub - Nobø Energy Control.""" +from __future__ import annotations + +from pynobo import nobo + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SUGGESTED_AREA, + ATTR_VIA_DEVICE, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import ATTR_SERIAL, ATTR_ZONE_ID, DOMAIN, NOBO_MANUFACTURER + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up any temperature sensors connected to the Nobø Ecohub.""" + + # Setup connection with hub + hub: nobo = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + NoboTemperatureSensor(component["serial"], hub) + for component in hub.components.values() + if component[ATTR_MODEL].has_temp_sensor + ) + + +class NoboTemperatureSensor(SensorEntity): + """A Nobø device with a temperature sensor.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = TEMP_CELSIUS + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_should_poll = False + + def __init__(self, serial: str, hub: nobo) -> None: + """Initialize the temperature sensor.""" + self._temperature: StateType = None + self._id = serial + self._nobo = hub + component = hub.components[self._id] + self._attr_unique_id = component[ATTR_SERIAL] + self._attr_name = "Temperature" + self._attr_has_entity_name = True + self._attr_device_info: DeviceInfo = { + ATTR_IDENTIFIERS: {(DOMAIN, component[ATTR_SERIAL])}, + ATTR_NAME: component[ATTR_NAME], + ATTR_MANUFACTURER: NOBO_MANUFACTURER, + ATTR_MODEL: component[ATTR_MODEL].name, + ATTR_VIA_DEVICE: (DOMAIN, hub.hub_info[ATTR_SERIAL]), + } + zone_id = component[ATTR_ZONE_ID] + if zone_id != "-1": + self._attr_device_info[ATTR_SUGGESTED_AREA] = hub.zones[zone_id][ATTR_NAME] + self._read_state() + + async def async_added_to_hass(self) -> None: + """Register callback from hub.""" + self._nobo.register_callback(self._after_update) + + async def async_will_remove_from_hass(self) -> None: + """Deregister callback from hub.""" + self._nobo.deregister_callback(self._after_update) + + @callback + def _read_state(self) -> None: + """Read the current state from the hub. This is a local call.""" + value = self._nobo.get_current_component_temperature(self._id) + if value is None: + self._attr_native_value = None + else: + self._attr_native_value = round(float(value), 1) + + @callback + def _after_update(self, hub) -> None: + self._read_state() + self.async_write_ha_state() diff --git a/homeassistant/components/nobo_hub/translations/nb.json b/homeassistant/components/nobo_hub/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/nobo_hub/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nobo_hub/translations/nl.json b/homeassistant/components/nobo_hub/translations/nl.json index 9a8e90bcdb530b77a964ec1f92db29e6aa9f0be2..13ead2a14607a95b31a2de54445d1cd789b7b484 100644 --- a/homeassistant/components/nobo_hub/translations/nl.json +++ b/homeassistant/components/nobo_hub/translations/nl.json @@ -15,6 +15,12 @@ "ip_address": "IP-adres", "serial": "Serienummer (12 cijfers)" } + }, + "user": { + "data": { + "device": "Ontdekte hubs" + }, + "description": "Selecteer een Nob\u00f8 Ecohub om te configureren." } } } diff --git a/homeassistant/components/notion/diagnostics.py b/homeassistant/components/notion/diagnostics.py index 9e1d6d3b7a4519b64ccc6b6d1bc2eacf44a129ac..9b0a070897c0297e83fc0f29a8e97b53ae4775b8 100644 --- a/homeassistant/components/notion/diagnostics.py +++ b/homeassistant/components/notion/diagnostics.py @@ -5,18 +5,26 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN CONF_DEVICE_KEY = "device_key" +CONF_HARDWARE_ID = "hardware_id" +CONF_LAST_BRIDGE_HARDWARE_ID = "last_bridge_hardware_id" +CONF_TITLE = "title" TO_REDACT = { CONF_DEVICE_KEY, CONF_EMAIL, + CONF_HARDWARE_ID, + CONF_LAST_BRIDGE_HARDWARE_ID, CONF_PASSWORD, + # Config entry title and unique ID may contain sensitive data: + CONF_TITLE, + CONF_UNIQUE_ID, CONF_USERNAME, } @@ -27,4 +35,7 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return async_redact_data(coordinator.data, TO_REDACT) + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "data": async_redact_data(coordinator.data, TO_REDACT), + } diff --git a/homeassistant/components/notion/manifest.json b/homeassistant/components/notion/manifest.json index fa19ef81c8c4a1c9744c3ed65ee06e5c07d0c9c7..1aac9693740d68da497275b7c8247ac2321b6e56 100644 --- a/homeassistant/components/notion/manifest.json +++ b/homeassistant/components/notion/manifest.json @@ -6,5 +6,6 @@ "requirements": ["aionotion==3.0.2"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", - "loggers": ["aionotion"] + "loggers": ["aionotion"], + "integration_type": "hub" } diff --git a/homeassistant/components/notion/translations/nb.json b/homeassistant/components/notion/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/notion/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/notion/translations/no.json b/homeassistant/components/notion/translations/no.json index b8ffb36e0405145369108d666ecef13ef8d7d90a..deb1a20f3cca375a58bcd2e708a24d52a8533167 100644 --- a/homeassistant/components/notion/translations/no.json +++ b/homeassistant/components/notion/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index 82a5e3a59a8bcf698d57b30e9e9499b1b66d8e14..0bc2cf12be29d99a0e92aa097944d05634b6ec46 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -6,7 +6,7 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CURRENCY_CENT, VOLUME_LITERS +from homeassistant.const import CURRENCY_CENT, VOLUME_LITERS from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -40,7 +40,6 @@ CONF_ALLOWED_FUEL_TYPES = [ ] CONF_DEFAULT_FUEL_TYPES = ["E10", "U91"] -ATTRIBUTION = "Data provided by NSW Government FuelCheck" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_STATION_ID): cv.positive_int, @@ -88,6 +87,8 @@ class StationPriceSensor( ): """Implementation of a sensor that reports the fuel price for a station.""" + _attr_attribution = "Data provided by NSW Government FuelCheck" + def __init__( self, coordinator: DataUpdateCoordinator[StationPriceData], @@ -121,7 +122,6 @@ class StationPriceSensor( return { ATTR_STATION_ID: self._station_id, ATTR_STATION_NAME: self._get_station_name(), - ATTR_ATTRIBUTION: ATTRIBUTION, } @property diff --git a/homeassistant/components/nuheat/translations/nb.json b/homeassistant/components/nuheat/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/nuheat/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuki/translations/bg.json b/homeassistant/components/nuki/translations/bg.json index 37e8e854866a8e750c2be8e84448333224d03330..1a6aff3fe4c6fae0e14d6d2babab7aeb9777fdeb 100644 --- a/homeassistant/components/nuki/translations/bg.json +++ b/homeassistant/components/nuki/translations/bg.json @@ -1,9 +1,15 @@ { "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": { "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": { + "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" + }, "user": { "data": { "port": "\u041f\u043e\u0440\u0442" diff --git a/homeassistant/components/nuki/translations/nb.json b/homeassistant/components/nuki/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/nuki/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nuki/translations/no.json b/homeassistant/components/nuki/translations/no.json index 1ae4eb03624421a1d68956d635657cd3394685c0..0cfb713ba6aa324f0e93e70de784f34ceffdf9cc 100644 --- a/homeassistant/components/nuki/translations/no.json +++ b/homeassistant/components/nuki/translations/no.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 27332e50b18cad14fa68e736fb560e2e3fc1e799..b4110736e555be1950c28625d8ba3cadaf6d4e64 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -148,7 +148,7 @@ def _serial_from_status(status: dict[str, str]) -> str | None: """Find the best serialvalue from the status.""" serial = status.get("device.serial") or status.get("ups.serial") if serial and ( - serial.lower() in NUT_FAKE_SERIAL or serial.count("0") == len(serial) + serial.lower() in NUT_FAKE_SERIAL or serial.count("0") == len(serial.strip()) ): return None return serial diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 64dc95d7b953984cdc9ace660f0b050ce69815cf..a8591349b56118c540fefdf22af1b231880706ee 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -90,7 +90,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.delay.start", name="Load Restart Delay", native_unit_of_measurement=TIME_SECONDS, - icon="mdi:timer-outline", + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -98,7 +98,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.delay.reboot", name="UPS Reboot Delay", native_unit_of_measurement=TIME_SECONDS, - icon="mdi:timer-outline", + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -106,7 +106,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.delay.shutdown", name="UPS Shutdown Delay", native_unit_of_measurement=TIME_SECONDS, - icon="mdi:timer-outline", + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -114,7 +114,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.timer.start", name="Load Start Timer", native_unit_of_measurement=TIME_SECONDS, - icon="mdi:timer-outline", + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -122,7 +122,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.timer.reboot", name="Load Reboot Timer", native_unit_of_measurement=TIME_SECONDS, - icon="mdi:timer-outline", + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -130,7 +130,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.timer.shutdown", name="Load Shutdown Timer", native_unit_of_measurement=TIME_SECONDS, - icon="mdi:timer-outline", + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -138,7 +138,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.test.interval", name="Self-Test Interval", native_unit_of_measurement=TIME_SECONDS, - icon="mdi:timer-outline", + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -369,7 +369,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.runtime", name="Battery Runtime", native_unit_of_measurement=TIME_SECONDS, - icon="mdi:timer-outline", + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -377,7 +377,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.runtime.low", name="Low Battery Runtime", native_unit_of_measurement=TIME_SECONDS, - icon="mdi:timer-outline", + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -385,7 +385,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.runtime.restart", name="Minimum Battery Runtime to Start", native_unit_of_measurement=TIME_SECONDS, - icon="mdi:timer-outline", + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), diff --git a/homeassistant/components/nut/translations/nb.json b/homeassistant/components/nut/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/nut/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nutrichef/manifest.json b/homeassistant/components/nutrichef/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..4d81d4881231fb2d62e1f513e519732d938f5f2d --- /dev/null +++ b/homeassistant/components/nutrichef/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "nutrichef", + "name": "Nutrichef", + "integration_type": "virtual", + "supported_by": "inkbird" +} diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 1e062bf3d36d1152123feea733e519f4a2fcd344..2e7495701a95afe99531d15452bdbf82992fb542 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -23,6 +23,7 @@ from homeassistant.util.unit_conversion import ( PressureConverter, SpeedConverter, ) +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import base_unique_id, device_info from .const import ( @@ -80,7 +81,7 @@ class NWSSensor(CoordinatorEntity, SensorEntity): self.entity_description = description self._attr_name = f"{station} {description.name}" - if not hass.config.units.is_metric: + if hass.config.units is US_CUSTOMARY_SYSTEM: self._attr_native_unit_of_measurement = description.unit_convert @property diff --git a/homeassistant/components/nws/translations/nb.json b/homeassistant/components/nws/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/nws/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 4684714d58cd33019519fd395b7dfd9406f40125..7963c1161a972648d0128ab32020ec00d7a7c2fb 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -107,7 +107,6 @@ class NWSWeather(WeatherEntity): self.coordinator_forecast = hass_data[COORDINATOR_FORECAST_HOURLY] self.station = self.nws.station - self.is_metric = units.is_metric self.mode = mode self.observation = None diff --git a/homeassistant/components/nzbget/translations/nb.json b/homeassistant/components/nzbget/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/nzbget/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index 3cb624190e77cdc8709fe5aee155a653c6498abb..664ad033cfe6fa418462e8d72f0719e86f6b7315 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -30,8 +30,6 @@ ATTR_NEXT_ARRIVAL = "next_arrival" ATTR_SECOND_NEXT_ARRIVAL = "second_next_arrival" ATTR_NEXT_DEPARTURE = "next_departure" -ATTRIBUTION = "Data retrieved from telematics.oasa.gr" - CONF_STOP_ID = "stop_id" CONF_ROUTE_ID = "route_id" @@ -68,6 +66,8 @@ def setup_platform( class OASATelematicsSensor(SensorEntity): """Implementation of the OASA Telematics sensor.""" + _attr_attribution = "Data retrieved from telematics.oasa.gr" + def __init__(self, data, stop_id, route_id, name): """Initialize the sensor.""" self.data = data @@ -111,7 +111,6 @@ class OASATelematicsSensor(SensorEntity): { ATTR_ROUTE_ID: self._times[0][ATTR_ROUTE_ID], ATTR_STOP_ID: self._stop_id, - ATTR_ATTRIBUTION: ATTRIBUTION, } ) params.update( diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 1d1c19584209c9df7dd6830a08560a09c3230f7d..8f6de2f0a28f095ac02d2175dfed190e0b7a2c5f 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -6,6 +6,7 @@ import logging from typing import cast from pyoctoprintapi import ApiError, OctoprintClient, PrinterOffline +from pyoctoprintapi.exceptions import UnauthorizedException import voluptuous as vol from yarl import URL @@ -24,6 +25,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo @@ -54,7 +56,7 @@ def ensure_valid_path(value): return value -PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, Platform.SENSOR] DEFAULT_NAME = "OctoPrint" CONF_NUMBER_OF_TOOLS = "number_of_tools" CONF_BED = "bed" @@ -226,6 +228,8 @@ class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): printer = None try: job = await self._octoprint.get_job_info() + except UnauthorizedException as err: + raise ConfigEntryAuthFailed from err except ApiError as err: raise UpdateFailed(err) from err @@ -238,6 +242,8 @@ class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): if not self._printer_offline: _LOGGER.debug("Unable to retrieve printer information: Printer offline") self._printer_offline = True + except UnauthorizedException as err: + raise ConfigEntryAuthFailed from err except ApiError as err: raise UpdateFailed(err) from err else: diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py new file mode 100644 index 0000000000000000000000000000000000000000..653c15f1843823cf97a663758c470841e1731a95 --- /dev/null +++ b/homeassistant/components/octoprint/camera.py @@ -0,0 +1,59 @@ +"""Support for OctoPrint binary camera.""" +from __future__ import annotations + +from pyoctoprintapi import OctoprintClient, WebcamSettings + +from homeassistant.components.mjpeg.camera import MjpegCamera +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 OctoprintDataUpdateCoordinator +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the available OctoPrint camera.""" + coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ]["coordinator"] + client: OctoprintClient = hass.data[DOMAIN][config_entry.entry_id]["client"] + device_id = config_entry.unique_id + + assert device_id is not None + + camera_info = await client.get_webcam_info() + + if not camera_info or not camera_info.enabled: + return + + async_add_entities( + [ + OctoprintCamera( + camera_info, + coordinator.device_info, + device_id, + ) + ] + ) + + +class OctoprintCamera(MjpegCamera): + """Representation of an OctoPrint Camera Stream.""" + + def __init__( + self, camera_settings: WebcamSettings, device_info: DeviceInfo, device_id: str + ) -> None: + """Initialize as a subclass of MjpegCamera.""" + super().__init__( + device_info=device_info, + mjpeg_url=camera_settings.stream_url, + name="OctoPrint Camera", + still_image_url=camera_settings.external_snapshot_url, + unique_id=device_id, + ) diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 1bd54e2214eeaacaea0b14ad3690773289e0d1c1..c1bdc623291857219a8ae2d67c55149e5b536d64 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -1,5 +1,9 @@ """Config flow for OctoPrint integration.""" +from __future__ import annotations + +from collections.abc import Mapping import logging +from typing import Any from pyoctoprintapi import ApiError, OctoprintClient, OctoprintException import voluptuous as vol @@ -16,6 +20,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -46,6 +51,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 api_key_task = None + _reauth_data = None def __init__(self) -> None: """Handle a config flow for OctoPrint.""" @@ -114,8 +120,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._user_input = user_input return self.async_show_progress_done(next_step_id="user") - async def _finish_config(self, user_input): + async def _finish_config(self, user_input: dict): """Finish the configuration setup.""" + existing_entry = await self.async_set_unique_id(self.unique_id) + if existing_entry is not None: + self.hass.config_entries.async_update_entry(existing_entry, data=user_input) + # Reload the config entry otherwise devices will remain unavailable + self.hass.async_create_task( + self.hass.config_entries.async_reload(existing_entry.entry_id) + ) + + return self.async_abort(reason="reauth_successful") + octoprint = self._get_octoprint_client(user_input) octoprint.set_api_key(user_input[CONF_API_KEY]) @@ -127,6 +143,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(discovery.upnp_uuid, raise_on_progress=False) self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) async def async_step_auth_failed(self, user_input): @@ -188,6 +205,41 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + """Handle reauthorization request from Octoprint.""" + self._reauth_data = dict(config) + + self.context.update( + { + "title_placeholders": {CONF_HOST: config[CONF_HOST]}, + } + ) + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauthorization flow.""" + assert self._reauth_data is not None + + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required( + CONF_USERNAME, default=self._reauth_data[CONF_USERNAME] + ): str, + } + ), + ) + + self.api_key_task = None + self._reauth_data[CONF_USERNAME] = user_input[CONF_USERNAME] + + return await self.async_step_get_api_key(self._reauth_data) + async def _async_get_auth_key(self, user_input: dict): """Get application api key.""" octoprint = self._get_octoprint_client(user_input) diff --git a/homeassistant/components/octoprint/manifest.json b/homeassistant/components/octoprint/manifest.json index 4086f9fbe207a1734b703e118925d48726de03f3..9bc2f0011c01dbad3399cf8e694a1c88f8d7954c 100644 --- a/homeassistant/components/octoprint/manifest.json +++ b/homeassistant/components/octoprint/manifest.json @@ -3,7 +3,7 @@ "name": "OctoPrint", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/octoprint", - "requirements": ["pyoctoprintapi==0.1.8"], + "requirements": ["pyoctoprintapi==0.1.9"], "codeowners": ["@rfleming71"], "zeroconf": ["_octoprint._tcp.local."], "ssdp": [ diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index ad493022dfc92e6c122be5e44de4f926afd3f6ba..fd140d5c00e30f357ae7ed138b37e32d3b57443e 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -71,8 +71,7 @@ async def async_setup_entry( device_id, ) ) - if new_tools: - async_add_entities(new_tools) + async_add_entities(new_tools) config_entry.async_on_unload(coordinator.async_add_listener(async_add_tool_sensors)) diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index 89e44a6a3a612b9f7794bf304760aa07a41f4904..23cdf6ce56ef53cbf7a8e5705f37a0191ba579e0 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -11,6 +11,11 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]" } + }, + "reauth_confirm": { + "data": { + "username": "[%key:common::config_flow::data::username%]" + } } }, "error": { @@ -21,7 +26,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "unknown": "[%key:common::config_flow::error::unknown%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "auth_failed": "Failed to retrieve application api key" + "auth_failed": "Failed to retrieve application api key", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "progress": { "get_api_key": "Open the OctoPrint UI and click 'Allow' on the Access Request for 'Home Assistant'." diff --git a/homeassistant/components/octoprint/translations/bg.json b/homeassistant/components/octoprint/translations/bg.json index 670311552c94f587fc541472553bc028f3a0989d..0635640be7d52c5b01740ad2343dcda3952fa7f8 100644 --- a/homeassistant/components/octoprint/translations/bg.json +++ b/homeassistant/components/octoprint/translations/bg.json @@ -3,6 +3,7 @@ "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\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "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", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "error": { @@ -10,10 +11,16 @@ "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { + "reauth_confirm": { + "data": { + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", "port": "\u041d\u043e\u043c\u0435\u0440 \u043d\u0430 \u043f\u043e\u0440\u0442", + "ssl": "\u0418\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 SSL", "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" } } diff --git a/homeassistant/components/octoprint/translations/ca.json b/homeassistant/components/octoprint/translations/ca.json index 2e7665ea7ec579d31961a3174bba7b15a9cbf23e..a8b4512da3cdd09d550fd9d5776b9681992f433f 100644 --- a/homeassistant/components/octoprint/translations/ca.json +++ b/homeassistant/components/octoprint/translations/ca.json @@ -4,6 +4,7 @@ "already_configured": "El dispositiu ja est\u00e0 configurat", "auth_failed": "No s'ha pogut obtenir la clau API de l'aplicaci\u00f3", "cannot_connect": "Ha fallat la connexi\u00f3", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "unknown": "Error inesperat" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "Obre la interf\u00edcie d'usuari d'OctoPrint i clica a 'Permet' a la sol\u00b7licitud d'acc\u00e9s de 'Home Assistant'." }, "step": { + "reauth_confirm": { + "data": { + "username": "Nom d'usuari" + } + }, "user": { "data": { "host": "Amfitri\u00f3", diff --git a/homeassistant/components/octoprint/translations/de.json b/homeassistant/components/octoprint/translations/de.json index 8cbe68469503ad788dc6f6174fcd9696bcebb48e..782920e2959376609debfc245e65303389a42696 100644 --- a/homeassistant/components/octoprint/translations/de.json +++ b/homeassistant/components/octoprint/translations/de.json @@ -4,6 +4,7 @@ "already_configured": "Ger\u00e4t ist bereits konfiguriert", "auth_failed": "Fehler beim Abrufen des Anwendungs-API-Schl\u00fcssels", "cannot_connect": "Verbindung fehlgeschlagen", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", "unknown": "Unerwarteter Fehler" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "\u00d6ffne die OctoPrint-Benutzeroberfl\u00e4che und dr\u00fccke bei der Zugriffsanfrage f\u00fcr \"Home Assistant\" auf \"Zulassen\"." }, "step": { + "reauth_confirm": { + "data": { + "username": "Benutzername" + } + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/octoprint/translations/el.json b/homeassistant/components/octoprint/translations/el.json index 60dc47229e2dfc232f51f2ef9117026d888f2cc9..f253e2e28d0f183f74d59c6181316a803740c1b1 100644 --- a/homeassistant/components/octoprint/translations/el.json +++ b/homeassistant/components/octoprint/translations/el.json @@ -4,6 +4,7 @@ "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", "auth_failed": "\u0391\u03c0\u03ad\u03c4\u03c5\u03c7\u03b5 \u03b7 \u03b1\u03bd\u03ac\u03ba\u03c4\u03b7\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03ba\u03bb\u03b5\u03b9\u03b4\u03b9\u03bf\u03cd api \u03c4\u03b7\u03c2 \u03b5\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae\u03c2", "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "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", "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "\u0391\u03bd\u03bf\u03af\u03be\u03c4\u03b5 \u03c4\u03bf OctoPrint UI \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf 'Allow' \u03c3\u03c4\u03bf \u03b1\u03af\u03c4\u03b7\u03bc\u03b1 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03b3\u03b9\u03b1 \u03c4\u03bf 'Home Assistant'." }, "step": { + "reauth_confirm": { + "data": { + "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7" + } + }, "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/octoprint/translations/en.json b/homeassistant/components/octoprint/translations/en.json index e0729b2785696a5fb68e21fc2c7e8604a9985564..eb05cd7fae462e38f5d777bcaaba538dcb6acf04 100644 --- a/homeassistant/components/octoprint/translations/en.json +++ b/homeassistant/components/octoprint/translations/en.json @@ -4,6 +4,7 @@ "already_configured": "Device is already configured", "auth_failed": "Failed to retrieve application api key", "cannot_connect": "Failed to connect", + "reauth_successful": "Re-authentication was successful", "unknown": "Unexpected error" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "Open the OctoPrint UI and click 'Allow' on the Access Request for 'Home Assistant'." }, "step": { + "reauth_confirm": { + "data": { + "username": "Username" + } + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/octoprint/translations/es.json b/homeassistant/components/octoprint/translations/es.json index 827e29e0fdebc373895045bd010acba669a7fd32..427b0151166d72f41acf993c42d21628a6f39ec8 100644 --- a/homeassistant/components/octoprint/translations/es.json +++ b/homeassistant/components/octoprint/translations/es.json @@ -4,6 +4,7 @@ "already_configured": "El dispositivo ya est\u00e1 configurado", "auth_failed": "No se pudo recuperar la clave API de la aplicaci\u00f3n", "cannot_connect": "No se pudo conectar", + "reauth_successful": "La autenticaci\u00f3n se volvi\u00f3 a realizar correctamente", "unknown": "Error inesperado" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "Abre la interfaz de usuario de OctoPrint y haz clic en 'Permitir' en la solicitud de acceso para 'Home Assistant'." }, "step": { + "reauth_confirm": { + "data": { + "username": "Nombre de usuario" + } + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/octoprint/translations/et.json b/homeassistant/components/octoprint/translations/et.json index f27dd9d77aafaae73143881a640ac3b54d9e37fb..20a6832255d9b7682f957433f6431de2b73adbf9 100644 --- a/homeassistant/components/octoprint/translations/et.json +++ b/homeassistant/components/octoprint/translations/et.json @@ -4,6 +4,7 @@ "already_configured": "Seade on juba h\u00e4\u00e4lestatud", "auth_failed": "Rakenduse API v\u00f5tme toomine nurjus", "cannot_connect": "\u00dchendamine nurjus", + "reauth_successful": "Taastuvastamine \u00f5nnestus", "unknown": "Ootamatu t\u00f5rge" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "Ava OctoPrinti kasutajaliides ja kl\u00f5psa Home Assistanti juurdep\u00e4\u00e4sutaotluses nuppu Luba." }, "step": { + "reauth_confirm": { + "data": { + "username": "Kasutajanimi" + } + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/octoprint/translations/fr.json b/homeassistant/components/octoprint/translations/fr.json index be78ad8b877fb5da84fd51c8a9067ceacbb2d2c1..ecb057d3831ed2267b5cf46fb2aafde927246f4a 100644 --- a/homeassistant/components/octoprint/translations/fr.json +++ b/homeassistant/components/octoprint/translations/fr.json @@ -4,6 +4,7 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "auth_failed": "\u00c9chec de la r\u00e9cup\u00e9ration de la cl\u00e9 API de l'application", "cannot_connect": "\u00c9chec de connexion", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "unknown": "Erreur inattendue" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "Ouvrez l'interface utilisateur d'OctoPrint et cliquez sur \u00abAutoriser\u00bb sur la demande d'acc\u00e8s pour \u00abHome Assistant\u00bb." }, "step": { + "reauth_confirm": { + "data": { + "username": "Nom d'utilisateur" + } + }, "user": { "data": { "host": "H\u00f4te", diff --git a/homeassistant/components/octoprint/translations/he.json b/homeassistant/components/octoprint/translations/he.json index 356676babee1fe1f04acfd9990b28d3e0e198975..dcb6c3a173ab3618775ecd50da72a4cdc7c132aa 100644 --- a/homeassistant/components/octoprint/translations/he.json +++ b/homeassistant/components/octoprint/translations/he.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "error": { @@ -10,6 +11,11 @@ "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { + "reauth_confirm": { + "data": { + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7", diff --git a/homeassistant/components/octoprint/translations/hu.json b/homeassistant/components/octoprint/translations/hu.json index 3f283b51189470dd36050467a6bc6321f163566f..b1d293c2c3f9dd4334dd285f2ebb4db04aca38bd 100644 --- a/homeassistant/components/octoprint/translations/hu.json +++ b/homeassistant/components/octoprint/translations/hu.json @@ -4,6 +4,7 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "auth_failed": "Nem siker\u00fclt lek\u00e9rni az alkalmaz\u00e1s api kulcs\u00e1t", "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "Nyissa meg az OctoPrint kezel\u0151 fel\u00fclet\u00e9t, \u00e9s kattintson az 'Allow' gombra a 'Home Assistant' hozz\u00e1f\u00e9r\u00e9si k\u00e9relemn\u00e9l." }, "step": { + "reauth_confirm": { + "data": { + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + }, "user": { "data": { "host": "C\u00edm", diff --git a/homeassistant/components/octoprint/translations/id.json b/homeassistant/components/octoprint/translations/id.json index 34675967d4fe112e142662a7b2845ba59082710a..f697193c39989be52ef70369278bb3ca8e3017fa 100644 --- a/homeassistant/components/octoprint/translations/id.json +++ b/homeassistant/components/octoprint/translations/id.json @@ -4,6 +4,7 @@ "already_configured": "Perangkat sudah dikonfigurasi", "auth_failed": "Gagal mengambil kunci API aplikasi", "cannot_connect": "Gagal terhubung", + "reauth_successful": "Autentikasi ulang berhasil", "unknown": "Kesalahan yang tidak diharapkan" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "Buka antarmuka OctoPrint dan klik 'Izinkan' pada Permintaan Akses untuk 'Home Assistant'." }, "step": { + "reauth_confirm": { + "data": { + "username": "Nama Pengguna" + } + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/octoprint/translations/it.json b/homeassistant/components/octoprint/translations/it.json index 639b304417dbafe91e38337f359492cf884356cd..0c7d1f1159bb0d069c9d443682718db886a014ba 100644 --- a/homeassistant/components/octoprint/translations/it.json +++ b/homeassistant/components/octoprint/translations/it.json @@ -4,6 +4,7 @@ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "auth_failed": "Impossibile recuperare la chiave API dell'applicazione", "cannot_connect": "Impossibile connettersi", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "unknown": "Errore imprevisto" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "Apri l'interfaccia utente di OctoPrint e fai clic su \"Consenti\" nella richiesta di accesso per \"Home Assistant\"." }, "step": { + "reauth_confirm": { + "data": { + "username": "Nome utente" + } + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/octoprint/translations/ja.json b/homeassistant/components/octoprint/translations/ja.json index a7ce93830cfdddeb3ada61fb784468a39729346a..5b54999c78256b3fcf2adc1e89c7b3016a9a0662 100644 --- a/homeassistant/components/octoprint/translations/ja.json +++ b/homeassistant/components/octoprint/translations/ja.json @@ -4,6 +4,7 @@ "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", "auth_failed": "\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3API \u30ad\u30fc\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f", "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f", "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "OctoPrint UI\u3092\u958b\u304d\u3001Home Assistant\u306e\u30a2\u30af\u30bb\u30b9\u30ea\u30af\u30a8\u30b9\u30c8\u3067\u3092 '\u8a31\u53ef' \u3092\u30af\u30ea\u30c3\u30af\u3057\u307e\u3059\u3002" }, "step": { + "reauth_confirm": { + "data": { + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + }, "user": { "data": { "host": "\u30db\u30b9\u30c8", diff --git a/homeassistant/components/octoprint/translations/nb.json b/homeassistant/components/octoprint/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..f9907ba2eaea60f67beb2800189aa5fbfb57e443 100644 --- a/homeassistant/components/octoprint/translations/nb.json +++ b/homeassistant/components/octoprint/translations/nb.json @@ -1,6 +1,17 @@ { "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + }, "step": { + "reauth_confirm": { + "data": { + "username": "Brukernavn" + } + }, "user": { "data": { "username": "Brukernavn" diff --git a/homeassistant/components/octoprint/translations/nl.json b/homeassistant/components/octoprint/translations/nl.json index fa8f5edc01a0ae1d3f05aee0ee000872b52ce1f3..5945353150364e8ec453b171f1c9c4fc5d02e26a 100644 --- a/homeassistant/components/octoprint/translations/nl.json +++ b/homeassistant/components/octoprint/translations/nl.json @@ -4,6 +4,7 @@ "already_configured": "Apparaat is al geconfigureerd", "auth_failed": "Kan applicatie API-sleutel niet ophalen", "cannot_connect": "Kan geen verbinding maken", + "reauth_successful": "Herauthenticatie geslaagd", "unknown": "Onverwachte fout" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "Open de OctoPrint UI en klik op 'Toestaan' op het toegangsverzoek voor 'Home Assistant'." }, "step": { + "reauth_confirm": { + "data": { + "username": "Gebruikersnaam" + } + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/octoprint/translations/no.json b/homeassistant/components/octoprint/translations/no.json index 0432b190e8ccc81c80c9c638b4a22714ed481e0f..fe6cec2cae135dcdafc8edcfee5a236d6942e544 100644 --- a/homeassistant/components/octoprint/translations/no.json +++ b/homeassistant/components/octoprint/translations/no.json @@ -4,6 +4,7 @@ "already_configured": "Enheten er allerede konfigurert", "auth_failed": "Kan ikke hente APAn\u00f8kkel for program", "cannot_connect": "Tilkobling mislyktes", + "reauth_successful": "Re-autentisering var vellykket", "unknown": "Uventet feil" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "\u00c5pne OctoPrint UI og klikk \"Tillat\" p\u00e5 tilgangsforesp\u00f8rselen for \"Home Assistant\"." }, "step": { + "reauth_confirm": { + "data": { + "username": "Brukernavn" + } + }, "user": { "data": { "host": "Vert", diff --git a/homeassistant/components/octoprint/translations/pl.json b/homeassistant/components/octoprint/translations/pl.json index e6a0c3ad680cfa46c583763d8160c658436f7dfc..a2ccd78204a7f19892c9b2c001295db3d1c0f815 100644 --- a/homeassistant/components/octoprint/translations/pl.json +++ b/homeassistant/components/octoprint/translations/pl.json @@ -4,6 +4,7 @@ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", "auth_failed": "Nie uda\u0142o si\u0119 pobra\u0107 klucza API aplikacji", "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "Otw\u00f3rz interfejs OctoPrint i kliknij \u201eZezw\u00f3l\u201d przy \u017c\u0105daniu dost\u0119pu do Home Assistanta." }, "step": { + "reauth_confirm": { + "data": { + "username": "Nazwa u\u017cytkownika" + } + }, "user": { "data": { "host": "Nazwa hosta lub adres IP", diff --git a/homeassistant/components/octoprint/translations/pt-BR.json b/homeassistant/components/octoprint/translations/pt-BR.json index f8af97f752625ec42124b0cbfe469280bd973b11..577e96495fb8705564a3116ed254e9afcda83752 100644 --- a/homeassistant/components/octoprint/translations/pt-BR.json +++ b/homeassistant/components/octoprint/translations/pt-BR.json @@ -4,6 +4,7 @@ "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", "auth_failed": "Falha ao recuperar a chave de API do aplicativo", "cannot_connect": "Falha ao conectar", + "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida", "unknown": "Erro inesperado" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "Abra a interface do usu\u00e1rio do OctoPrint e clique em 'Permitir' na solicita\u00e7\u00e3o de acesso para 'Assistente dom\u00e9stico'." }, "step": { + "reauth_confirm": { + "data": { + "username": "Usu\u00e1rio" + } + }, "user": { "data": { "host": "Nome do host", diff --git a/homeassistant/components/octoprint/translations/ru.json b/homeassistant/components/octoprint/translations/ru.json index 48d99ebe673fcfbbd33164d2608a3aecea31084f..c51f0e4a0dde9fc57f10b7f61e84256b09ee89c8 100644 --- a/homeassistant/components/octoprint/translations/ru.json +++ b/homeassistant/components/octoprint/translations/ru.json @@ -4,6 +4,7 @@ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", "auth_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u043b\u044e\u0447 API \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f.", "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "\u041e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u0432\u0435\u0431-\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441 OctoPrint \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 '\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c' \u0432 \u0437\u0430\u043f\u0440\u043e\u0441\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0434\u043b\u044f 'Home Assistant'." }, "step": { + "reauth_confirm": { + "data": { + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + } + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/octoprint/translations/sv.json b/homeassistant/components/octoprint/translations/sv.json index f3b9760aca21f7090adb9454174cfe4f1bfb63e6..a349fb9c97358b10444598c5a4fe973f155f6428 100644 --- a/homeassistant/components/octoprint/translations/sv.json +++ b/homeassistant/components/octoprint/translations/sv.json @@ -4,6 +4,7 @@ "already_configured": "Enheten \u00e4r redan konfigurerad", "auth_failed": "Det gick inte att h\u00e4mta applikationens API-nyckel", "cannot_connect": "Det gick inte att ansluta.", + "reauth_successful": "\u00c5terautentisering lyckades", "unknown": "Ov\u00e4ntat fel" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "\u00d6ppna OctoPrint UI och klicka p\u00e5 \"Till\u00e5t\" p\u00e5 \u00e5tkomstbeg\u00e4ran f\u00f6r \"Home Assistant\"." }, "step": { + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, "user": { "data": { "host": "V\u00e4rd", diff --git a/homeassistant/components/octoprint/translations/tr.json b/homeassistant/components/octoprint/translations/tr.json index 7e3e24c7b659db81fba451e4850969a8d0204ee3..5099c5b9d15e66ed6f0cb70e51b70b04cf26d8ac 100644 --- a/homeassistant/components/octoprint/translations/tr.json +++ b/homeassistant/components/octoprint/translations/tr.json @@ -4,6 +4,7 @@ "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "auth_failed": "Uygulama API anahtar\u0131 al\u0131namad\u0131", "cannot_connect": "Ba\u011flanma hatas\u0131", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", "unknown": "Beklenmeyen hata" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "OctoPrint UI'sini a\u00e7\u0131n ve 'Ev Asistan\u0131' i\u00e7in Eri\u015fim \u0130ste\u011finde '\u0130zin Ver'i t\u0131klay\u0131n." }, "step": { + "reauth_confirm": { + "data": { + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + }, "user": { "data": { "host": "Sunucu", diff --git a/homeassistant/components/octoprint/translations/zh-Hans.json b/homeassistant/components/octoprint/translations/zh-Hans.json new file mode 100644 index 0000000000000000000000000000000000000000..4849b3ce4756fe600d19045af678ed6d3eb041de --- /dev/null +++ b/homeassistant/components/octoprint/translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u91cd\u65b0\u8ba4\u8bc1\u6210\u529f" + }, + "step": { + "reauth_confirm": { + "data": { + "username": "\u7528\u6237\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/octoprint/translations/zh-Hant.json b/homeassistant/components/octoprint/translations/zh-Hant.json index f26a39d6a029418f2ae3c7dc102e36238eaf124d..840e3665c57a037604f3b095f11a63f66c62a85a 100644 --- a/homeassistant/components/octoprint/translations/zh-Hant.json +++ b/homeassistant/components/octoprint/translations/zh-Hant.json @@ -4,6 +4,7 @@ "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "auth_failed": "\u63a5\u6536\u61c9\u7528\u7a0b\u5f0f API \u91d1\u9470\u5931\u6557", "cannot_connect": "\u9023\u7dda\u5931\u6557", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "error": { @@ -15,6 +16,11 @@ "get_api_key": "\u958b\u555f OctoPrint UI \u4e26\u65bc 'Home Assistant' \u5b58\u53d6\u8acb\u6c42\u4e0a\u9ede\u9078 '\u5141\u8a31'\u3002" }, "step": { + "reauth_confirm": { + "data": { + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + }, "user": { "data": { "host": "\u4e3b\u6a5f\u7aef", diff --git a/homeassistant/components/omnilogic/translations/nb.json b/homeassistant/components/omnilogic/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/omnilogic/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oncue/const.py b/homeassistant/components/oncue/const.py index 5adabc84bcf5a1305b74453d5138fc1417b04ce0..bf2483699876253090ab27a269742fd8ea423b7e 100644 --- a/homeassistant/components/oncue/const.py +++ b/homeassistant/components/oncue/const.py @@ -7,3 +7,7 @@ import aiohttp DOMAIN = "oncue" CONNECTION_EXCEPTIONS = (asyncio.TimeoutError, aiohttp.ClientError) + +CONNECTION_ESTABLISHED_KEY: str = "NetworkConnectionEstablished" + +VALUE_UNAVAILABLE: str = "--" diff --git a/homeassistant/components/oncue/entity.py b/homeassistant/components/oncue/entity.py index d1942c532e7efce6437ea8e4a27a19ecec6ea02f..60a3826df422aba7ecf4ae1a32b0c1e5f6749546 100644 --- a/homeassistant/components/oncue/entity.py +++ b/homeassistant/components/oncue/entity.py @@ -11,7 +11,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import DOMAIN +from .const import CONNECTION_ESTABLISHED_KEY, DOMAIN, VALUE_UNAVAILABLE class OncueEntity(CoordinatorEntity, Entity): @@ -53,3 +53,23 @@ class OncueEntity(CoordinatorEntity, Entity): device: OncueDevice = self.coordinator.data[self._device_id] sensor: OncueSensor = device.sensors[self.entity_description.key] return sensor.value + + @property + def available(self) -> bool: + """Return if entity is available.""" + # The binary sensor that tracks the connection should not go unavailable. + if self.entity_description.key != CONNECTION_ESTABLISHED_KEY: + # If Kohler returns -- the entity is unavailable. + if self._oncue_value == VALUE_UNAVAILABLE: + return False + # If the cloud is reporting that the generator is not connected + # this also indicates the data is not available. + # The battery voltage sensor reports 0.0 rather than -- hence the purpose of this check. + device: OncueDevice = self.coordinator.data[self._device_id] + conn_established: OncueSensor = device.sensors[CONNECTION_ESTABLISHED_KEY] + if ( + conn_established is not None + and conn_established.value == VALUE_UNAVAILABLE + ): + return False + return super().available diff --git a/homeassistant/components/oncue/manifest.json b/homeassistant/components/oncue/manifest.json index e0533129d9499b5298bbb2d61d0628e79e9d89a3..d9dd247ded97c89d86e42bd892f9fa7124a5595a 100644 --- a/homeassistant/components/oncue/manifest.json +++ b/homeassistant/components/oncue/manifest.json @@ -10,7 +10,7 @@ ], "documentation": "https://www.home-assistant.io/integrations/oncue", "requirements": ["aiooncue==0.3.4"], - "codeowners": ["@bdraco"], + "codeowners": ["@bdraco", "@peterager"], "iot_class": "cloud_polling", "loggers": ["aiooncue"] } diff --git a/homeassistant/components/oncue/translations/nb.json b/homeassistant/components/oncue/translations/nb.json index ef1398553b590d02cefaa5f6ded4de5c2d5325ba..5e81332ac80876a0cfbb02dfa05ec3138d17b610 100644 --- a/homeassistant/components/oncue/translations/nb.json +++ b/homeassistant/components/oncue/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/open_meteo/manifest.json b/homeassistant/components/open_meteo/manifest.json index ccfc7dbd51da986640c2dadc17962378a0f052b2..8a6d1561d96952513693988aae1918d9d5dfe00a 100644 --- a/homeassistant/components/open_meteo/manifest.json +++ b/homeassistant/components/open_meteo/manifest.json @@ -6,5 +6,6 @@ "requirements": ["open-meteo==0.2.1"], "dependencies": ["zone"], "codeowners": ["@frenck"], + "integration_type": "service", "iot_class": "cloud_polling" } diff --git a/homeassistant/components/open_meteo/translations/bg.json b/homeassistant/components/open_meteo/translations/bg.json index 24a982db36af4e60bc87c441fba3bb7882058350..2675f2ca1171a3c148b9cd6efd7cf87b5e4348ff 100644 --- a/homeassistant/components/open_meteo/translations/bg.json +++ b/homeassistant/components/open_meteo/translations/bg.json @@ -2,6 +2,9 @@ "config": { "step": { "user": { + "data": { + "zone": "\u0417\u043e\u043d\u0430" + }, "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435, \u043a\u043e\u0435\u0442\u043e \u0434\u0430 \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u0442\u0435 \u0437\u0430 \u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430 \u0437\u0430 \u0432\u0440\u0435\u043c\u0435\u0442\u043e" } } diff --git a/homeassistant/components/openexchangerates/config_flow.py b/homeassistant/components/openexchangerates/config_flow.py index 3c22f3e0fe004368b9d4b1914390604997f3a320..13060e197180c648bfb749e29b34157dfb2b9242 100644 --- a/homeassistant/components/openexchangerates/config_flow.py +++ b/homeassistant/components/openexchangerates/config_flow.py @@ -126,7 +126,3 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except asyncio.TimeoutError as err: raise AbortFlow("timeout_connect") from err return self.currencies - - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Handle import from yaml/configuration.""" - return await self.async_step_user(import_config) diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index 76573b351b37fbf629d5f01bc6eb8b646438c290..f73f78cb4e846cd4c9e6a63d96a3e585aca27cac 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -1,68 +1,20 @@ """Support for openexchangerates.org exchange rates service.""" from __future__ import annotations -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_BASE, CONF_NAME, CONF_QUOTE +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_QUOTE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_BASE, DOMAIN, LOGGER +from .const import DOMAIN from .coordinator import OpenexchangeratesCoordinator ATTRIBUTION = "Data provided by openexchangerates.org" -DEFAULT_NAME = "Exchange Rate Sensor" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_QUOTE): cv.string, - vol.Optional(CONF_BASE, default=DEFAULT_BASE): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Open Exchange Rates sensor.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.11.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - LOGGER.warning( - "Configuration of Open Exchange Rates integration in YAML is deprecated and " - "will be removed in Home Assistant 2022.11.; Your existing configuration " - "has been imported into the UI automatically and can be safely removed from" - " your configuration.yaml file" - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/openexchangerates/strings.json b/homeassistant/components/openexchangerates/strings.json index 57180e367aa17b216eabf21d1aa2aa72d85015a0..d8837f468a4008f17da1f99dc19d65fe8387b76f 100644 --- a/homeassistant/components/openexchangerates/strings.json +++ b/homeassistant/components/openexchangerates/strings.json @@ -23,11 +23,5 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Open Exchange Rates YAML configuration is being removed", - "description": "Configuring Open Exchange Rates using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Open Exchange Rates YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/openexchangerates/translations/ca.json b/homeassistant/components/openexchangerates/translations/ca.json index 634ea7578e7389ad2b992ac095ee40fc30579100..81d359599ae19741b8d1437db1a7f5b813a3dd4a 100644 --- a/homeassistant/components/openexchangerates/translations/ca.json +++ b/homeassistant/components/openexchangerates/translations/ca.json @@ -26,8 +26,8 @@ }, "issues": { "deprecated_yaml": { - "description": "La configuraci\u00f3 d'Open Exchange Rates mitjan\u00e7ant YAML s'eliminar\u00e0 de Home Assistant.\n\nLa configuraci\u00f3 YAML existent s'ha importat autom\u00e0ticament a la interf\u00edcie d'usuari.\n\nElimina la configuraci\u00f3 YAML d'Open Exchange Rates del fitxer configuration.yaml i reinicia Home Assistant per solucionar aquest problema.", - "title": "La configuraci\u00f3 YAML d'Open Exchange Rates est\u00e0 sent eliminada" + "description": "La configuraci\u00f3 d'Open Exchange Rates mitjan\u00e7ant YAML s'ha eliminat de Home Assistant.\n\nElimina la configuraci\u00f3 YAML d'Open Exchange Rates del fitxer configuration.yaml i reinicia Home Assistant per solucionar aquest problema.", + "title": "La configuraci\u00f3 YAML d'Open Exchange Rates s'ha eliminat" } } } \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/de.json b/homeassistant/components/openexchangerates/translations/de.json index 51a0294c81672965faaeb1de33e58cbd9e2f7e06..a0f974d3374611fc6a36cb8272908775a7a1535d 100644 --- a/homeassistant/components/openexchangerates/translations/de.json +++ b/homeassistant/components/openexchangerates/translations/de.json @@ -26,8 +26,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Die Konfiguration von Open Exchange Rates mittels YAML wird entfernt.\n\nDeine bestehende YAML-Konfiguration wurde automatisch in die Benutzeroberfl\u00e4che importiert.\n\nEntferne die YAML-Konfiguration f\u00fcr Open Exchange Rates aus deiner configuration.yaml und starte den Home Assistant neu, um dieses Problem zu beheben.", - "title": "Die Open Exchange Rates YAML-Konfiguration wird entfernt" + "description": "Das Konfigurieren von Open Exchange Rates mit YAML wurde entfernt. \n\nEntferne die YAML-Konfiguration f\u00fcr Open Exchange Rates aus deiner configuration.yaml-Datei und starte Home Assistant neu, um dieses Problem zu beheben.", + "title": "Die Open Exchange Rates YAML-Konfiguration wurde entfernt" } } } \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/en.json b/homeassistant/components/openexchangerates/translations/en.json index 011953904ffaf450cfdbaeb6be696d58fd59dacf..f4827c4df4d1513b249592e5c8601c5a76e659e4 100644 --- a/homeassistant/components/openexchangerates/translations/en.json +++ b/homeassistant/components/openexchangerates/translations/en.json @@ -26,8 +26,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Configuring Open Exchange Rates using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Open Exchange Rates YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", - "title": "The Open Exchange Rates YAML configuration is being removed" + "description": "Configuring Open Exchange Rates using YAML has been removed.\n\nRemove the Open Exchange Rates YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", + "title": "The Open Exchange Rates YAML configuration has been removed" } } } \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/es.json b/homeassistant/components/openexchangerates/translations/es.json index c5cf1b266e2ac710cd03da1d9553af371bc2eb3f..b71ef65277029aafc5202055099b25ad8203ca17 100644 --- a/homeassistant/components/openexchangerates/translations/es.json +++ b/homeassistant/components/openexchangerates/translations/es.json @@ -26,8 +26,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Se va a eliminar la configuraci\u00f3n de Open Exchange Rates mediante YAML. \n\nTu configuraci\u00f3n YAML existente se ha importado a la IU autom\u00e1ticamente. \n\nElimina la configuraci\u00f3n YAML de Open Exchange Rates de tu archivo configuration.yaml y reinicia Home Assistant para solucionar este problema.", - "title": "Se va a eliminar la configuraci\u00f3n YAML de Open Exchange Rates" + "description": "Se ha eliminado la configuraci\u00f3n de Open Exchange Rates mediante YAML. \n\nElimina la configuraci\u00f3n YAML de Open Exchange Rates de tu archivo configuration.yaml y reinicia Home Assistant para solucionar este problema.", + "title": "Se ha eliminado la configuraci\u00f3n YAML de Open Exchange Rates" } } } \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/et.json b/homeassistant/components/openexchangerates/translations/et.json index fbeed4f844302a42178dc61474b742b3cf9856e0..45ed7fb1cfcfebf73d8b977ed9876325227fd23b 100644 --- a/homeassistant/components/openexchangerates/translations/et.json +++ b/homeassistant/components/openexchangerates/translations/et.json @@ -26,8 +26,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Open Exchange Rates konfigureerimine YAML-i abil eemaldatakse.\n\nOlemasolev YAML-konfiguratsioon on automaatselt kasutajaliidesesse imporditud.\n\nEemalda Open Exchange Rates YAML-konfiguratsioon oma configuration.yaml-failist ja k\u00e4ivita Home Assistant uuesti, et see probleem lahendada.", - "title": "Open Exchange Rates YAML-konfiguratsioon eemaldatakse" + "description": "Open Exchange Rates konfigureerimine YAML-i abil eemaldati.\n\nEemalda Open Exchange Rates YAML-konfiguratsioon oma configuration.yaml-failist ja k\u00e4ivita Home Assistant uuesti, et see probleem lahendada.", + "title": "Open Exchange Rates YAML-konfiguratsioon eemaldati" } } } \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/fr.json b/homeassistant/components/openexchangerates/translations/fr.json index 2e8b1c42cac3a11d665c02a24318bf9298815895..c6d0bb2444455a5011ad1b5bdc809e6cae765de5 100644 --- a/homeassistant/components/openexchangerates/translations/fr.json +++ b/homeassistant/components/openexchangerates/translations/fr.json @@ -26,7 +26,7 @@ }, "issues": { "deprecated_yaml": { - "title": "La configuration YAML pour Open Exchange Rates sera bient\u00f4t supprim\u00e9e" + "title": "La configuration YAML pour Open Exchange Rates a \u00e9t\u00e9 supprim\u00e9e" } } } \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/hu.json b/homeassistant/components/openexchangerates/translations/hu.json index 83f3ebae2b36f2d47e0f3ee2fe6b28b22806e3f0..51843cd899ad7ffecbd90cb1f3c62435d0e7b26b 100644 --- a/homeassistant/components/openexchangerates/translations/hu.json +++ b/homeassistant/components/openexchangerates/translations/hu.json @@ -26,7 +26,7 @@ }, "issues": { "deprecated_yaml": { - "description": "Az Open Exchange Rates konfigur\u00e1l\u00e1sa YAML haszn\u00e1lat\u00e1val elt\u00e1vol\u00edt\u00e1sra ker\u00fcl.\n\nA megl\u00e9v\u0151 YAML konfigur\u00e1ci\u00f3 automatikusan import\u00e1l\u00e1sra ker\u00fclt a felhaszn\u00e1l\u00f3i fel\u00fcletre.\n\nA probl\u00e9ma megold\u00e1s\u00e1hoz t\u00e1vol\u00edtsa el a YAML konfigur\u00e1ci\u00f3t a configuration.yaml f\u00e1jlb\u00f3l, \u00e9s ind\u00edtsa \u00fajra a Home Assistantot.", + "description": "Az Open Exchange Rates konfigur\u00e1l\u00e1sa YAML haszn\u00e1lat\u00e1val elt\u00e1vol\u00edt\u00e1sra ker\u00fcl.\n\nA probl\u00e9ma megold\u00e1s\u00e1hoz t\u00e1vol\u00edtsa el a YAML konfigur\u00e1ci\u00f3t a configuration.yaml f\u00e1jlb\u00f3l, \u00e9s ind\u00edtsa \u00fajra a Home Assistantot.", "title": "Az Open Exchange Rates YAML-konfigur\u00e1ci\u00f3ja elt\u00e1vol\u00edt\u00e1sra ker\u00fcl" } } diff --git a/homeassistant/components/openexchangerates/translations/id.json b/homeassistant/components/openexchangerates/translations/id.json index 29f4a6eaabfb4e6c390c56c3c6bea49586e1651b..d53a8e9ef5f853e78d916924f0d3a0f0a3dab0c6 100644 --- a/homeassistant/components/openexchangerates/translations/id.json +++ b/homeassistant/components/openexchangerates/translations/id.json @@ -26,8 +26,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Proses konfigurasi Open Exchange Rates lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Open Exchange Rates dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Open Exchange Rates dalam proses penghapusan" + "description": "Proses konfigurasi Integrasi Open Exchange Rates lewat YAML telah dihapus.\n\nHapus konfigurasi YAML Integrasi Open Exchange Rates dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Open Exchange Rates telah dihapus" } } } \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/it.json b/homeassistant/components/openexchangerates/translations/it.json index 38fca960f500eaebfcc8dec2f2b24927f90e6bb0..491a44e75084b58d2f73358843a51a2e35d16800 100644 --- a/homeassistant/components/openexchangerates/translations/it.json +++ b/homeassistant/components/openexchangerates/translations/it.json @@ -26,8 +26,8 @@ }, "issues": { "deprecated_yaml": { - "description": "La configurazione di Open Exchange Rates tramite YAML sar\u00e0 rimossa.\n\nLa configurazione YAML esistente \u00e8 stata importata automaticamente nell'interfaccia utente.\n\nRimuovi la configurazione YAML di Open Exchange Rates dal file configuration.yaml e riavvia Home Assistant per risolvere questo problema.", - "title": "La configurazione YAML di Open Exchange Rates sar\u00e0 rimossa" + "description": "La configurazione di Open Exchange Rates tramite YAML \u00e8 stata rimossa. \n\nRimuovi la configurazione YAML di Open Exchange Rates dal file configuration.yaml e riavvia Home Assistant per risolvere questo problema.", + "title": "La configurazione YAML di Open Exchange Rates \u00e8 stata rimossa" } } } \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/nb.json b/homeassistant/components/openexchangerates/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/openexchangerates/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/no.json b/homeassistant/components/openexchangerates/translations/no.json index 93a85a767c8b8aec6e4cfab51e8b9a3957729ef1..1e810f5a52e0defb7f36414de4eb9fdb5b8ba88b 100644 --- a/homeassistant/components/openexchangerates/translations/no.json +++ b/homeassistant/components/openexchangerates/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Tjenesten er allerede konfigurert", "cannot_connect": "Tilkobling mislyktes", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "timeout_connect": "Tidsavbrudd oppretter forbindelse" }, "error": { @@ -26,8 +26,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Konfigurering av \u00e5pne valutakurser ved hjelp av YAML blir fjernet. \n\n Din eksisterende YAML-konfigurasjon har blitt importert til brukergrensesnittet automatisk. \n\n Fjern Open Exchange Rates YAML-konfigurasjonen fra configuration.yaml-filen og start Home Assistant p\u00e5 nytt for \u00e5 fikse dette problemet.", - "title": "Open Exchange Rates YAML-konfigurasjonen blir fjernet" + "description": "Konfigurering av \u00e5pne valutakurser med YAML er fjernet. \n\n Fjern Open Exchange Rates YAML-konfigurasjonen fra configuration.yaml-filen og start Home Assistant p\u00e5 nytt for \u00e5 fikse dette problemet.", + "title": "Open Exchange Rates YAML-konfigurasjonen er fjernet" } } } \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/pl.json b/homeassistant/components/openexchangerates/translations/pl.json index e7de6c30cec22263cccc549ef1f5ca9e0696dc8b..a9bb2278d9034b8dd1f1d80461739f7096b981b8 100644 --- a/homeassistant/components/openexchangerates/translations/pl.json +++ b/homeassistant/components/openexchangerates/translations/pl.json @@ -26,8 +26,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Konfiguracja Open Exchange Rates przy u\u017cyciu YAML zostanie usuni\u0119ta. \n\nTwoja istniej\u0105ca konfiguracja YAML zosta\u0142a automatycznie zaimportowana do interfejsu u\u017cytkownika. \n\nUsu\u0144 konfiguracj\u0119 YAML z pliku configuration.yaml i uruchom ponownie Home Assistanta, aby rozwi\u0105za\u0107 ten problem.", - "title": "Konfiguracja YAML dla Open Exchange Rates zostanie usuni\u0119ta" + "description": "Konfiguracja Open Exchange Rates przy u\u017cyciu YAML zosta\u0142a usuni\u0119ta. \n\nUsu\u0144 konfiguracj\u0119 YAML z pliku configuration.yaml i uruchom ponownie Home Assistanta, aby rozwi\u0105za\u0107 ten problem.", + "title": "Konfiguracja YAML dla Open Exchange Rates zosta\u0142a usuni\u0119ta" } } } \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/ru.json b/homeassistant/components/openexchangerates/translations/ru.json index 1707c8e8646da4415c9e4532bbdf56f43ad88d4b..cfc8edb0e8df380ab58d0aaf69ef6e2ee56748e5 100644 --- a/homeassistant/components/openexchangerates/translations/ru.json +++ b/homeassistant/components/openexchangerates/translations/ru.json @@ -26,8 +26,8 @@ }, "issues": { "deprecated_yaml": { - "description": "\u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Open Exchange Rates \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e YAML \u0431\u0443\u0434\u0435\u0442 \u0443\u0434\u0430\u043b\u0435\u043d\u0430.\n\n\u0412\u0430\u0448\u0430 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0430\u044f YAML-\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0430. \u0423\u0434\u0430\u043b\u0438\u0442\u0435 \u0435\u0451 \u0438\u0437 \u0444\u0430\u0439\u043b\u0430 configuration.yaml \u0438 \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u0435 Home Assistant, \u0447\u0442\u043e\u0431\u044b \u0443\u0441\u0442\u0440\u0430\u043d\u0438\u0442\u044c \u044d\u0442\u0443 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443.", - "title": "\u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Open Exchange Rates \u0447\u0435\u0440\u0435\u0437 YAML \u0431\u0443\u0434\u0435\u0442 \u0443\u0434\u0430\u043b\u0435\u043d\u0430" + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \"Open Exchange Rates\" \u0442\u0435\u043f\u0435\u0440\u044c \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0435\u0440\u0435\u0437 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0439 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441.\n\n\u0412\u0430\u0448\u0430 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0430\u044f \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f YAML \u043d\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f Home Assistant.\n\n\u0423\u0434\u0430\u043b\u0438\u0442\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u044e\u0449\u0443\u044e \u0447\u0430\u0441\u0442\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0438\u0437 \u0444\u0430\u0439\u043b\u0430 configuration.yaml \u0438 \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u0435 Home Assistant, \u0447\u0442\u043e\u0431\u044b \u0443\u0441\u0442\u0440\u0430\u043d\u0438\u0442\u044c \u044d\u0442\u0443 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443.", + "title": "\u0423\u0434\u0430\u043b\u0435\u043d\u0430 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Open Exchange Rates \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e YAML" } } } \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/tr.json b/homeassistant/components/openexchangerates/translations/tr.json index 436e6bbb07b3af80a969080fec7f46a836fb350b..4149d5bd52d6682368a9e8de9205260f8f29a0f6 100644 --- a/homeassistant/components/openexchangerates/translations/tr.json +++ b/homeassistant/components/openexchangerates/translations/tr.json @@ -26,8 +26,8 @@ }, "issues": { "deprecated_yaml": { - "description": "YAML kullanarak A\u00e7\u0131k D\u00f6viz Kurlar\u0131n\u0131 yap\u0131land\u0131rma kald\u0131r\u0131l\u0131yor. \n\n Mevcut YAML yap\u0131land\u0131rman\u0131z otomatik olarak kullan\u0131c\u0131 aray\u00fcz\u00fcne aktar\u0131ld\u0131. \n\n Open Exchange Rates YAML yap\u0131land\u0131rmas\u0131n\u0131 configuration.yaml dosyan\u0131zdan kald\u0131r\u0131n ve bu sorunu gidermek i\u00e7in Home Assistant'\u0131 yeniden ba\u015flat\u0131n.", - "title": "A\u00e7\u0131k D\u00f6viz Kurlar\u0131 YAML yap\u0131land\u0131rmas\u0131 kald\u0131r\u0131l\u0131yor" + "description": "YAML kullanarak A\u00e7\u0131k D\u00f6viz Kurlar\u0131n\u0131 yap\u0131land\u0131rma kald\u0131r\u0131ld\u0131. \n\n Open Exchange Rates YAML yap\u0131land\u0131rmas\u0131n\u0131 configuration.yaml dosyan\u0131zdan kald\u0131r\u0131n ve bu sorunu gidermek i\u00e7in Home Assistant'\u0131 yeniden ba\u015flat\u0131n.", + "title": "A\u00e7\u0131k D\u00f6viz Kurlar\u0131 YAML yap\u0131land\u0131rmas\u0131 kald\u0131r\u0131ld\u0131" } } } \ No newline at end of file diff --git a/homeassistant/components/openexchangerates/translations/zh-Hant.json b/homeassistant/components/openexchangerates/translations/zh-Hant.json index c9f8df654e48e5179c4ab985d6997e5bb84f3b33..d2b9b7ecb27b5b1a5c57d32dd8652357d522f2d5 100644 --- a/homeassistant/components/openexchangerates/translations/zh-Hant.json +++ b/homeassistant/components/openexchangerates/translations/zh-Hant.json @@ -26,8 +26,8 @@ }, "issues": { "deprecated_yaml": { - "description": "\u4f7f\u7528 YAML \u8a2d\u5b9a\u7684 Open Exchange Rates \u5373\u5c07\u9032\u884c\u79fb\u9664\u3002\n\n\u65e2\u6709\u7684 YAML \u8a2d\u5b9a\u5c07\u81ea\u52d5\u532f\u5165\u81f3 UI \u5167\u3002\n\n\u8acb\u65bc configuration.yaml \u6a94\u6848\u4e2d\u79fb\u9664 Open Exchange Rates YAML \u8a2d\u5b9a\u4e26\u91cd\u65b0\u555f\u52d5 Home Assistant \u4ee5\u4fee\u6b63\u6b64\u554f\u984c\u3002", - "title": "Open Exchange Rates YAML \u8a2d\u5b9a\u5373\u5c07\u79fb\u9664" + "description": "\u4f7f\u7528 YAML \u8a2d\u5b9a\u7684 Open Exchange Rates \u5df2\u79fb\u9664\u3002\n\n\u8acb\u65bc configuration.yaml \u6a94\u6848\u4e2d\u79fb\u9664 Open Exchange Rates YAML \u8a2d\u5b9a\u4e26\u91cd\u65b0\u555f\u52d5 Home Assistant \u4ee5\u4fee\u6b63\u6b64\u554f\u984c\u3002", + "title": "Open Exchange Rates YAML \u8a2d\u5b9a\u5df2\u79fb\u9664" } } } \ No newline at end of file diff --git a/homeassistant/components/opengarage/sensor.py b/homeassistant/components/opengarage/sensor.py index bf75cd34998e9df95742e78307f7aa0055737952..5e9591e8b6c4ebbdefc605248f61d7a95faa6da9 100644 --- a/homeassistant/components/opengarage/sensor.py +++ b/homeassistant/components/opengarage/sensor.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( LENGTH_CENTIMETERS, PERCENTAGE, - SIGNAL_STRENGTH_DECIBELS, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant, callback @@ -37,7 +37,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/opengarage/translations/nb.json b/homeassistant/components/opengarage/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/opengarage/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 66579eb81731d9a31e314943184edd7d02a90a00..4c96d88f3212a8a4132d77a377cbebb36425a31f 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_LATITUDE, @@ -42,9 +41,6 @@ EVENT_OPENSKY_ENTRY = f"{DOMAIN}_entry" EVENT_OPENSKY_EXIT = f"{DOMAIN}_exit" SCAN_INTERVAL = timedelta(seconds=12) # opensky public limit is 10 seconds -OPENSKY_ATTRIBUTION = ( - "Information provided by the OpenSky Network (https://opensky-network.org)" -) OPENSKY_API_URL = "https://opensky-network.org/api/states/all" OPENSKY_API_FIELDS = [ ATTR_ICAO24, @@ -101,6 +97,10 @@ def setup_platform( class OpenSkySensor(SensorEntity): """Open Sky Network Sensor.""" + _attr_attribution = ( + "Information provided by the OpenSky Network (https://opensky-network.org)" + ) + def __init__(self, hass, name, latitude, longitude, radius, altitude): """Initialize the sensor.""" self._session = requests.Session() @@ -188,11 +188,6 @@ class OpenSkySensor(SensorEntity): self._state = len(currently_tracked) self._previously_tracked = currently_tracked - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: OPENSKY_ATTRIBUTION} - @property def native_unit_of_measurement(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/opentherm_gw/manifest.json b/homeassistant/components/opentherm_gw/manifest.json index 97767ab8383049178bfc2707706aafe2df50e3db..99a10bc1539dacb375697f3b92fbdd041f01d256 100644 --- a/homeassistant/components/opentherm_gw/manifest.json +++ b/homeassistant/components/opentherm_gw/manifest.json @@ -2,7 +2,7 @@ "domain": "opentherm_gw", "name": "OpenTherm Gateway", "documentation": "https://www.home-assistant.io/integrations/opentherm_gw", - "requirements": ["pyotgw==2.0.3"], + "requirements": ["pyotgw==2.1.1"], "codeowners": ["@mvn23"], "config_flow": true, "iot_class": "local_push", diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 365a3ab247a883f01914c73155dcdd838d415812..fb649bce759a54cb32555fce8cf5bee38586eaa5 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -2,11 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Callable from typing import Any from pyopenuv import Client -from pyopenuv.errors import OpenUvError import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -20,20 +18,16 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( aiohttp_client, config_validation as cv, entity_registry, ) -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import verify_domain_control +from homeassistant.helpers.update_coordinator import CoordinatorEntity, UpdateFailed from .const import ( CONF_FROM_WINDOW, @@ -45,13 +39,10 @@ from .const import ( DOMAIN, LOGGER, ) +from .coordinator import OpenUvCoordinator CONF_ENTRY_ID = "entry_id" -DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60 - -TOPIC_UPDATE = f"{DOMAIN}_data_update" - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] SERVICE_NAME_UPDATE_DATA = "update_data" @@ -127,53 +118,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _verify_domain_control = verify_domain_control(hass, DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) - openuv = OpenUV( - hass, - entry, - Client( - entry.data[CONF_API_KEY], - entry.data.get(CONF_LATITUDE, hass.config.latitude), - entry.data.get(CONF_LONGITUDE, hass.config.longitude), - altitude=entry.data.get(CONF_ELEVATION, hass.config.elevation), - session=websession, - ), + client = Client( + entry.data[CONF_API_KEY], + entry.data.get(CONF_LATITUDE, hass.config.latitude), + entry.data.get(CONF_LONGITUDE, hass.config.longitude), + altitude=entry.data.get(CONF_ELEVATION, hass.config.elevation), + session=websession, ) - # We disable the client's request retry abilities here to avoid a lengthy (and - # blocking) startup: - openuv.client.disable_request_retries() + async def async_update_protection_data() -> dict[str, Any]: + """Update binary sensor (protection window) data.""" + low = entry.options.get(CONF_FROM_WINDOW, DEFAULT_FROM_WINDOW) + high = entry.options.get(CONF_TO_WINDOW, DEFAULT_TO_WINDOW) + return await client.uv_protection_window(low=low, high=high) - try: - await openuv.async_update() - except HomeAssistantError as err: - LOGGER.error("Config entry failed: %s", err) - raise ConfigEntryNotReady from err + coordinators: dict[str, OpenUvCoordinator] = { + coordinator_name: OpenUvCoordinator( + hass, + name=coordinator_name, + latitude=client.latitude, + longitude=client.longitude, + update_method=update_method, + ) + for coordinator_name, update_method in ( + (DATA_UV, client.uv_index), + (DATA_PROTECTION_WINDOW, async_update_protection_data), + ) + } - # Once we've successfully authenticated, we re-enable client request retries: - openuv.client.enable_request_retries() + # We disable the client's request retry abilities here to avoid a lengthy (and + # blocking) startup; then, if the initial update is successful, we re-enable client + # request retries: + client.disable_request_retries() + init_tasks = [ + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators.values() + ] + await asyncio.gather(*init_tasks) + client.enable_request_retries() hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = openuv + hass.data[DOMAIN][entry.entry_id] = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - @callback - def extract_openuv(func: Callable) -> Callable: - """Define a decorator to get the correct OpenUV object for a service call.""" - - async def wrapper(call: ServiceCall) -> None: - """Wrap the service function.""" - openuv: OpenUV = hass.data[DOMAIN][call.data[CONF_ENTRY_ID]] - - try: - await func(call, openuv) - except OpenUvError as err: - raise HomeAssistantError( - f'Error while executing "{call.service}": {err}' - ) from err - - return wrapper - # We determine entity IDs needed to help the user migrate from deprecated services: current_uv_index_entity_id = async_get_entity_id_from_unique_id_suffix( hass, entry, "current_uv_index" @@ -183,8 +171,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) @_verify_domain_control - @extract_openuv - async def update_data(call: ServiceCall, openuv: OpenUV) -> None: + async def update_data(call: ServiceCall) -> None: """Refresh all OpenUV data.""" LOGGER.debug("Refreshing all OpenUV data") async_log_deprecated_service_call( @@ -194,12 +181,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: [protection_window_entity_id, current_uv_index_entity_id], "2022.12.0", ) - await openuv.async_update() - async_dispatcher_send(hass, TOPIC_UPDATE) + + tasks = [coordinator.async_refresh() for coordinator in coordinators.values()] + try: + await asyncio.gather(*tasks) + except UpdateFailed as err: + raise HomeAssistantError(err) from err @_verify_domain_control - @extract_openuv - async def update_uv_index_data(call: ServiceCall, openuv: OpenUV) -> None: + async def update_uv_index_data(call: ServiceCall) -> None: """Refresh OpenUV UV index data.""" LOGGER.debug("Refreshing OpenUV UV index data") async_log_deprecated_service_call( @@ -209,12 +199,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: [current_uv_index_entity_id], "2022.12.0", ) - await openuv.async_update_uv_index_data() - async_dispatcher_send(hass, TOPIC_UPDATE) + + try: + await coordinators[DATA_UV].async_request_refresh() + except UpdateFailed as err: + raise HomeAssistantError(err) from err @_verify_domain_control - @extract_openuv - async def update_protection_data(call: ServiceCall, openuv: OpenUV) -> None: + async def update_protection_data(call: ServiceCall) -> None: """Refresh OpenUV protection window data.""" LOGGER.debug("Refreshing OpenUV protection window data") async_log_deprecated_service_call( @@ -224,8 +216,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: [protection_window_entity_id], "2022.12.0", ) - await openuv.async_update_protection_data() - async_dispatcher_send(hass, TOPIC_UPDATE) + + try: + await coordinators[DATA_PROTECTION_WINDOW].async_request_refresh() + except UpdateFailed as err: + raise HomeAssistantError(err) from err service_schema = vol.Schema( { @@ -283,106 +278,35 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -class OpenUV: - """Define a generic OpenUV object.""" - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry, client: Client) -> None: - """Initialize.""" - self._update_protection_data_debouncer = Debouncer( - hass, - LOGGER, - cooldown=DEFAULT_DEBOUNCER_COOLDOWN_SECONDS, - immediate=True, - function=self._async_update_protection_data, - ) - - self._update_uv_index_data_debouncer = Debouncer( - hass, - LOGGER, - cooldown=DEFAULT_DEBOUNCER_COOLDOWN_SECONDS, - immediate=True, - function=self._async_update_uv_index_data, - ) - - self._entry = entry - self.client = client - self.data: dict[str, Any] = {DATA_PROTECTION_WINDOW: {}, DATA_UV: {}} - - async def _async_update_protection_data(self) -> None: - """Update binary sensor (protection window) data.""" - low = self._entry.options.get(CONF_FROM_WINDOW, DEFAULT_FROM_WINDOW) - high = self._entry.options.get(CONF_TO_WINDOW, DEFAULT_TO_WINDOW) - - try: - data = await self.client.uv_protection_window(low=low, high=high) - except OpenUvError as err: - raise HomeAssistantError( - f"Error during protection data update: {err}" - ) from err - - self.data[DATA_PROTECTION_WINDOW] = data.get("result") - - async def _async_update_uv_index_data(self) -> None: - """Update sensor (uv index, etc) data.""" - try: - data = await self.client.uv_index() - except OpenUvError as err: - raise HomeAssistantError( - f"Error during UV index data update: {err}" - ) from err - - self.data[DATA_UV] = data.get("result") - - async def async_update_protection_data(self) -> None: - """Update binary sensor (protection window) data with a debouncer.""" - await self._update_protection_data_debouncer.async_call() - - async def async_update_uv_index_data(self) -> None: - """Update sensor (uv index, etc) data with a debouncer.""" - await self._update_uv_index_data_debouncer.async_call() - - async def async_update(self) -> None: - """Update sensor/binary sensor data.""" - tasks = [self.async_update_protection_data(), self.async_update_uv_index_data()] - await asyncio.gather(*tasks) - - -class OpenUvEntity(Entity): +class OpenUvEntity(CoordinatorEntity): """Define a generic OpenUV entity.""" _attr_has_entity_name = True - def __init__(self, openuv: OpenUV, description: EntityDescription) -> None: + def __init__( + self, coordinator: OpenUvCoordinator, description: EntityDescription + ) -> None: """Initialize.""" + super().__init__(coordinator) + self._attr_extra_state_attributes = {} - self._attr_should_poll = False self._attr_unique_id = ( - f"{openuv.client.latitude}_{openuv.client.longitude}_{description.key}" + f"{coordinator.latitude}_{coordinator.longitude}_{description.key}" ) self.entity_description = description - self.openuv = openuv @callback - def async_update_state(self) -> None: - """Update the state.""" - self.update_from_latest_data() + def _handle_coordinator_update(self) -> None: + """Respond to a DataUpdateCoordinator update.""" + self._update_from_latest_data() self.async_write_ha_state() - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.update_from_latest_data() - self.async_on_remove( - async_dispatcher_connect(self.hass, TOPIC_UPDATE, self.async_update_state) - ) - - async def async_update(self) -> None: - """Update the entity. - - Only used by the generic entity update service. Should be implemented by each - OpenUV platform. - """ + @callback + def _update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" raise NotImplementedError - def update_from_latest_data(self) -> None: - """Update the sensor using the latest data.""" - raise NotImplementedError + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self._update_from_latest_data() diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index b1c962932b7d9001ddd6881dedcb2e0fea5fed26..1e69af66eec5304e881af4fd0d0a7deee68f4041 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -10,6 +10,7 @@ from homeassistant.util.dt import as_local, parse_datetime, utcnow from . import OpenUvEntity from .const import DATA_PROTECTION_WINDOW, DOMAIN, LOGGER, TYPE_PROTECTION_WINDOW +from .coordinator import OpenUvCoordinator ATTR_PROTECTION_WINDOW_ENDING_TIME = "end_time" ATTR_PROTECTION_WINDOW_ENDING_UV = "end_uv" @@ -26,32 +27,27 @@ BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: + # Once we've successfully authenticated, we re-enable client request retries: """Set up an OpenUV sensor based on a config entry.""" - openuv = hass.data[DOMAIN][entry.entry_id] + coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] + async_add_entities( - [OpenUvBinarySensor(openuv, BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW)] + [ + OpenUvBinarySensor( + coordinators[DATA_PROTECTION_WINDOW], + BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW, + ) + ] ) class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): """Define a binary sensor for OpenUV.""" - async def async_update(self) -> None: - """Update the entity. - - Only used by the generic entity update service. - """ - await self.openuv.async_update_protection_data() - self.async_update_state() - @callback - def update_from_latest_data(self) -> None: - """Update the state.""" - if not (data := self.openuv.data[DATA_PROTECTION_WINDOW]): - self._attr_available = False - return - - self._attr_available = True + def _update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" + data = self.coordinator.data for key in ("from_time", "to_time", "from_uv", "to_uv"): if not data.get(key): diff --git a/homeassistant/components/openuv/coordinator.py b/homeassistant/components/openuv/coordinator.py new file mode 100644 index 0000000000000000000000000000000000000000..993970658efd766ee157638e0008d3f443c85499 --- /dev/null +++ b/homeassistant/components/openuv/coordinator.py @@ -0,0 +1,55 @@ +"""Define an update coordinator for OpenUV.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any, cast + +from pyopenuv.errors import OpenUvError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60 + + +class OpenUvCoordinator(DataUpdateCoordinator): + """Define an OpenUV data coordinator.""" + + update_method: Callable[[], Awaitable[dict[str, Any]]] + + def __init__( + self, + hass: HomeAssistant, + *, + name: str, + latitude: str, + longitude: str, + update_method: Callable[[], Awaitable[dict[str, Any]]], + ) -> None: + """Initialize.""" + super().__init__( + hass, + LOGGER, + name=name, + update_method=update_method, + request_refresh_debouncer=Debouncer( + hass, + LOGGER, + cooldown=DEFAULT_DEBOUNCER_COOLDOWN_SECONDS, + immediate=True, + ), + ) + + self.latitude = latitude + self.longitude = longitude + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from OpenUV.""" + try: + data = await self.update_method() + except OpenUvError as err: + raise UpdateFailed(f"Error during protection data update: {err}") from err + return cast(dict[str, Any], data["result"]) diff --git a/homeassistant/components/openuv/diagnostics.py b/homeassistant/components/openuv/diagnostics.py index 02b56ce0e9079a83b72c38f134bf798525421143..99c5f89d4560aaa69c7ecb1b369761cb7d2ac674 100644 --- a/homeassistant/components/openuv/diagnostics.py +++ b/homeassistant/components/openuv/diagnostics.py @@ -5,18 +5,27 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UNIQUE_ID, +) from homeassistant.core import HomeAssistant -from . import OpenUV from .const import DOMAIN +from .coordinator import OpenUvCoordinator CONF_COORDINATES = "coordinates" +CONF_TITLE = "title" TO_REDACT = { CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, + # Config entry title and unique ID may contain sensitive data: + CONF_TITLE, + CONF_UNIQUE_ID, } @@ -24,12 +33,15 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - openuv: OpenUV = hass.data[DOMAIN][entry.entry_id] - - return { - "entry": { - "data": async_redact_data(entry.data, TO_REDACT), - "options": async_redact_data(entry.options, TO_REDACT), + coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] + + return async_redact_data( + { + "entry": entry.as_dict(), + "data": { + coordinator_name: coordinator.data + for coordinator_name, coordinator in coordinators.items() + }, }, - "data": async_redact_data(openuv.data, TO_REDACT), - } + TO_REDACT, + ) diff --git a/homeassistant/components/openuv/manifest.json b/homeassistant/components/openuv/manifest.json index cd9bbfa8edf956e153b257aba899c2e087ddd278..5e89f495b037d7e45e9ddd4386a070ce8626e9a7 100644 --- a/homeassistant/components/openuv/manifest.json +++ b/homeassistant/components/openuv/manifest.json @@ -6,5 +6,6 @@ "requirements": ["pyopenuv==2022.04.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", - "loggers": ["pyopenuv"] + "loggers": ["pyopenuv"], + "integration_type": "service" } diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index ff28062da3775d0dd463d2957d8d97cf6dcb0a34..dd8d1587f49aa6168bda4297c7624f5a1d6b499f 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -28,6 +28,7 @@ from .const import ( TYPE_SAFE_EXPOSURE_TIME_5, TYPE_SAFE_EXPOSURE_TIME_6, ) +from .coordinator import OpenUvCoordinator ATTR_MAX_UV_TIME = "time" @@ -122,31 +123,23 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a OpenUV sensor based on a config entry.""" - openuv = hass.data[DOMAIN][entry.entry_id] + coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] + async_add_entities( - [OpenUvSensor(openuv, description) for description in SENSOR_DESCRIPTIONS] + [ + OpenUvSensor(coordinators[DATA_UV], description) + for description in SENSOR_DESCRIPTIONS + ] ) class OpenUvSensor(OpenUvEntity, SensorEntity): """Define a binary sensor for OpenUV.""" - async def async_update(self) -> None: - """Update the entity. - - Only used by the generic entity update service. - """ - await self.openuv.async_update_uv_index_data() - self.async_update_state() - @callback - def update_from_latest_data(self) -> None: + def _update_from_latest_data(self) -> None: """Update the state.""" - if (data := self.openuv.data[DATA_UV]) is None: - self._attr_available = False - return - - self._attr_available = True + data = self.coordinator.data if self.entity_description.key == TYPE_CURRENT_OZONE_LEVEL: self._attr_native_value = data["ozone"] diff --git a/homeassistant/components/openuv/translations/sv.json b/homeassistant/components/openuv/translations/sv.json index 9f1620fb980df2abee46662d43adad7031fa47d5..073d160dece2a877fb6a833e715f2381bb30a9c5 100644 --- a/homeassistant/components/openuv/translations/sv.json +++ b/homeassistant/components/openuv/translations/sv.json @@ -18,6 +18,16 @@ } } }, + "issues": { + "deprecated_service_multiple_alternate_targets": { + "description": "Uppdatera eventuella automatiseringar eller skript som anv\u00e4nder den h\u00e4r tj\u00e4nsten f\u00f6r att ist\u00e4llet anv\u00e4nda tj\u00e4nsten ` {alternate_service} ` med ett av dessa enhets-ID:n som m\u00e5l: ` {alternate_targets} `.", + "title": "Tj\u00e4nsten {deprecated_service} tas bort" + }, + "deprecated_service_single_alternate_target": { + "description": "Uppdatera eventuella automatiseringar eller skript som anv\u00e4nder den h\u00e4r tj\u00e4nsten f\u00f6r att ist\u00e4llet anv\u00e4nda tj\u00e4nsten ` {alternate_service} ` med ` {alternate_targets} ` som m\u00e5l.", + "title": "Tj\u00e4nsten {deprecated_service} tas bort" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/openuv/translations/tr.json b/homeassistant/components/openuv/translations/tr.json index d5caa40721a9d4c40cc923816d4f2ba7b5ddad3a..cf70500f213e38f793614652167beed368dde271 100644 --- a/homeassistant/components/openuv/translations/tr.json +++ b/homeassistant/components/openuv/translations/tr.json @@ -18,6 +18,16 @@ } } }, + "issues": { + "deprecated_service_multiple_alternate_targets": { + "description": "Bu hizmeti kullanan t\u00fcm otomasyonlar\u0131 veya komut dosyalar\u0131n\u0131, bunun yerine hedef olarak \u015fu varl\u0131k kimliklerinden biriyle ` {alternate_service} ` hizmetini kullanacak \u015fekilde g\u00fcncelleyin: ` {alternate_targets} `.", + "title": "{deprecated_service} hizmeti kald\u0131r\u0131l\u0131yor" + }, + "deprecated_service_single_alternate_target": { + "description": "Bu hizmeti kullanan t\u00fcm otomasyonlar\u0131 veya komut dosyalar\u0131n\u0131, hedef olarak `{alternate_targets}` ile `{alternate_service}` hizmetini kullanacak \u015fekilde g\u00fcncelleyin.", + "title": "{deprecated_service} hizmeti kald\u0131r\u0131l\u0131yor" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/openweathermap/translations/bg.json b/homeassistant/components/openweathermap/translations/bg.json index a46653b9fedddab7490633653b8d100ccfa42038..844dacd9cd260105fe571b61d98fc5d082e99e3e 100644 --- a/homeassistant/components/openweathermap/translations/bg.json +++ b/homeassistant/components/openweathermap/translations/bg.json @@ -4,7 +4,7 @@ "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": { - "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447" }, "step": { @@ -15,7 +15,7 @@ "latitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0448\u0438\u0440\u0438\u043d\u0430", "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430", "mode": "\u0420\u0435\u0436\u0438\u043c", - "name": "\u0418\u043c\u0435 \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430" + "name": "\u0418\u043c\u0435" } } } diff --git a/homeassistant/components/oralb/__init__.py b/homeassistant/components/oralb/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..61547b5e4328f5167b4906afdecfa598ec288f59 --- /dev/null +++ b/homeassistant/components/oralb/__init__.py @@ -0,0 +1,49 @@ +"""The OralB integration.""" +from __future__ import annotations + +import logging + +from oralb_ble import OralBBluetoothDeviceData + +from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothProcessorCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OralB BLE device from a config entry.""" + address = entry.unique_id + assert address is not None + data = OralBBluetoothDeviceData() + coordinator = hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=data.update, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload( + coordinator.async_start() + ) # only start after all platforms have had a chance to subscribe + 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/oralb/config_flow.py b/homeassistant/components/oralb/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..28e16c7f8a7c1dd1a19e24cf8424686ac9ab0c31 --- /dev/null +++ b/homeassistant/components/oralb/config_flow.py @@ -0,0 +1,94 @@ +"""Config flow for oralb ble integration.""" +from __future__ import annotations + +from typing import Any + +from oralb_ble import OralBBluetoothDeviceData as DeviceData +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class OralBConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for oralb.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_device: DeviceData | None = None + self._discovered_devices: dict[str, str] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + device = DeviceData() + if not device.supported(discovery_info): + return self.async_abort(reason="not_supported") + self._discovery_info = discovery_info + self._discovered_device = device + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + assert self._discovered_device is not None + device = self._discovered_device + assert self._discovery_info is not None + discovery_info = self._discovery_info + title = device.title or device.get_device_name() or discovery_info.name + if user_input is not None: + return self.async_create_entry(title=title, data={}) + + self._set_confirm_only() + placeholders = {"name": title} + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="bluetooth_confirm", description_placeholders=placeholders + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self._discovered_devices[address], data={} + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass, False): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + device = DeviceData() + if device.supported(discovery_info): + self._discovered_devices[address] = ( + device.title or device.get_device_name() or discovery_info.name + ) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)} + ), + ) diff --git a/homeassistant/components/oralb/const.py b/homeassistant/components/oralb/const.py new file mode 100644 index 0000000000000000000000000000000000000000..54ed2c49fba463150ab2e01b0c86627c9bc65f96 --- /dev/null +++ b/homeassistant/components/oralb/const.py @@ -0,0 +1,3 @@ +"""Constants for the OralB integration.""" + +DOMAIN = "oralb" diff --git a/homeassistant/components/oralb/device.py b/homeassistant/components/oralb/device.py new file mode 100644 index 0000000000000000000000000000000000000000..0b9da5c3779b41508c413aee1fe6efca845748c8 --- /dev/null +++ b/homeassistant/components/oralb/device.py @@ -0,0 +1,31 @@ +"""Support for OralB devices.""" +from __future__ import annotations + +from oralb_ble import DeviceKey, SensorDeviceInfo + +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothEntityKey, +) +from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME +from homeassistant.helpers.entity import DeviceInfo + + +def device_key_to_bluetooth_entity_key( + device_key: DeviceKey, +) -> PassiveBluetoothEntityKey: + """Convert a device key to an entity key.""" + return PassiveBluetoothEntityKey(device_key.key, device_key.device_id) + + +def sensor_device_info_to_hass( + sensor_device_info: SensorDeviceInfo, +) -> DeviceInfo: + """Convert a oralb device info to a sensor device info.""" + hass_device_info = DeviceInfo({}) + if sensor_device_info.name is not None: + hass_device_info[ATTR_NAME] = sensor_device_info.name + if sensor_device_info.manufacturer is not None: + hass_device_info[ATTR_MANUFACTURER] = sensor_device_info.manufacturer + if sensor_device_info.model is not None: + hass_device_info[ATTR_MODEL] = sensor_device_info.model + return hass_device_info diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..cad6167228cce440066e0e31d004254d72d1675a --- /dev/null +++ b/homeassistant/components/oralb/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "oralb", + "name": "Oral-B", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/oralb", + "bluetooth": [ + { + "manufacturer_id": 220 + } + ], + "requirements": ["oralb-ble==0.10.0"], + "dependencies": ["bluetooth"], + "codeowners": ["@bdraco"], + "iot_class": "local_push" +} diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..6fbc19b092ab8aca01a4056d8e3560a9d1e21ecb --- /dev/null +++ b/homeassistant/components/oralb/sensor.py @@ -0,0 +1,119 @@ +"""Support for OralB sensors.""" +from __future__ import annotations + +from typing import Optional, Union + +from oralb_ble import OralBSensor, SensorUpdate + +from homeassistant import config_entries +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothDataUpdate, + PassiveBluetoothProcessorCoordinator, + PassiveBluetoothProcessorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, TIME_SECONDS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .device import device_key_to_bluetooth_entity_key, sensor_device_info_to_hass + +SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { + OralBSensor.TIME: SensorEntityDescription( + key=OralBSensor.TIME, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=TIME_SECONDS, + ), + OralBSensor.SECTOR: SensorEntityDescription( + key=OralBSensor.SECTOR, + ), + OralBSensor.NUMBER_OF_SECTORS: SensorEntityDescription( + key=OralBSensor.NUMBER_OF_SECTORS, + ), + OralBSensor.SECTOR_TIMER: SensorEntityDescription( + key=OralBSensor.SECTOR_TIMER, + entity_registry_enabled_default=False, + ), + OralBSensor.TOOTHBRUSH_STATE: SensorEntityDescription( + key=OralBSensor.TOOTHBRUSH_STATE + ), + OralBSensor.PRESSURE: SensorEntityDescription(key=OralBSensor.PRESSURE), + OralBSensor.MODE: SensorEntityDescription( + key=OralBSensor.MODE, + ), + OralBSensor.SIGNAL_STRENGTH: SensorEntityDescription( + key=OralBSensor.SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +} + + +def sensor_update_to_bluetooth_data_update( + sensor_update: SensorUpdate, +) -> PassiveBluetoothDataUpdate: + """Convert a sensor update to a bluetooth data update.""" + return PassiveBluetoothDataUpdate( + devices={ + device_id: sensor_device_info_to_hass(device_info) + for device_id, device_info in sensor_update.devices.items() + }, + entity_descriptions={ + device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ + device_key.key + ] + for device_key in sensor_update.entity_descriptions + }, + entity_data={ + device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value + for device_key, sensor_values in sensor_update.entity_values.items() + }, + entity_names={ + device_key_to_bluetooth_entity_key(device_key): sensor_values.name + for device_key, sensor_values in sensor_update.entity_values.items() + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the OralB BLE sensors.""" + coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + entry.async_on_unload( + processor.async_add_entities_listener( + OralBBluetoothSensorEntity, async_add_entities + ) + ) + entry.async_on_unload(coordinator.async_register_processor(processor)) + + +class OralBBluetoothSensorEntity( + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[Optional[Union[str, int]]] + ], + SensorEntity, +): + """Representation of a OralB sensor.""" + + @property + def native_value(self) -> str | int | None: + """Return the native value.""" + return self.processor.entity_data.get(self.entity_key) diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..a045d84771e33be840d45c347e679a26088d389c --- /dev/null +++ b/homeassistant/components/oralb/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:component::bluetooth::config::step::user::data::address%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "not_supported": "Device not supported", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/oralb/translations/bg.json b/homeassistant/components/oralb/translations/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..af9a13197df4d429442f3f4d514e3edb736ab4ea --- /dev/null +++ b/homeassistant/components/oralb/translations/bg.json @@ -0,0 +1,21 @@ +{ + "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", + "no_devices_found": "\u041d\u044f\u043c\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430", + "not_supported": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 {name}?" + }, + "user": { + "data": { + "address": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0437\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/ca.json b/homeassistant/components/oralb/translations/ca.json new file mode 100644 index 0000000000000000000000000000000000000000..c121ff7408cf7ab8f265c29b052f02d74d77e341 --- /dev/null +++ b/homeassistant/components/oralb/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "no_devices_found": "No s'han trobat dispositius a la xarxa", + "not_supported": "Dispositiu no compatible" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Vols configurar {name}?" + }, + "user": { + "data": { + "address": "Dispositiu" + }, + "description": "Tria un dispositiu a configurar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/de.json b/homeassistant/components/oralb/translations/de.json new file mode 100644 index 0000000000000000000000000000000000000000..4c5720ec6fbd6826c88b37db019ea23333fa99b6 --- /dev/null +++ b/homeassistant/components/oralb/translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", + "not_supported": "Ger\u00e4t nicht unterst\u00fctzt" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "M\u00f6chtest du {name} einrichten?" + }, + "user": { + "data": { + "address": "Ger\u00e4t" + }, + "description": "W\u00e4hle ein Ger\u00e4t zum Einrichten aus" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/en.json b/homeassistant/components/oralb/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..ebd9760c161d868d218aa47eedf7a3c0d0dfb817 --- /dev/null +++ b/homeassistant/components/oralb/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "no_devices_found": "No devices found on the network", + "not_supported": "Device not supported" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Do you want to setup {name}?" + }, + "user": { + "data": { + "address": "Device" + }, + "description": "Choose a device to setup" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/es.json b/homeassistant/components/oralb/translations/es.json new file mode 100644 index 0000000000000000000000000000000000000000..ae0ab01acdf738f162acfbbbcbacf666d890aa28 --- /dev/null +++ b/homeassistant/components/oralb/translations/es.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en curso", + "no_devices_found": "No se encontraron dispositivos en la red", + "not_supported": "Dispositivo no compatible" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u00bfQuieres configurar {name}?" + }, + "user": { + "data": { + "address": "Dispositivo" + }, + "description": "Elige un dispositivo para configurar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/et.json b/homeassistant/components/oralb/translations/et.json new file mode 100644 index 0000000000000000000000000000000000000000..170815ec87ee5f51f2b3c01791aaef15f2000b41 --- /dev/null +++ b/homeassistant/components/oralb/translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "already_in_progress": "Seadistamine on juba k\u00e4imas", + "no_devices_found": "V\u00f5rgust seadmeid ei leitud", + "not_supported": "Seadet ei toetata" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Kas seadistada {name}?" + }, + "user": { + "data": { + "address": "Seade" + }, + "description": "Vali h\u00e4\u00e4lestatav seade" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/fr.json b/homeassistant/components/oralb/translations/fr.json new file mode 100644 index 0000000000000000000000000000000000000000..8ddb4af4dbc5603c3d4b4736c33cbb755baea76d --- /dev/null +++ b/homeassistant/components/oralb/translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "not_supported": "Appareil non pris en charge" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Voulez-vous configurer {name}\u00a0?" + }, + "user": { + "data": { + "address": "Appareil" + }, + "description": "S\u00e9lectionnez l'appareil \u00e0 configurer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/hu.json b/homeassistant/components/oralb/translations/hu.json new file mode 100644 index 0000000000000000000000000000000000000000..4668ffea41696296cf59192c6561163e058b2a49 --- /dev/null +++ b/homeassistant/components/oralb/translations/hu.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A be\u00e1ll\u00edt\u00e1si folyamat m\u00e1r el lett kezdve", + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "not_supported": "Eszk\u00f6z nem t\u00e1mogatott" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?" + }, + "user": { + "data": { + "address": "Eszk\u00f6z" + }, + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/it.json b/homeassistant/components/oralb/translations/it.json new file mode 100644 index 0000000000000000000000000000000000000000..b19851b36ee1dbecc616a8a2780a3eb896680c16 --- /dev/null +++ b/homeassistant/components/oralb/translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il processo di configurazione \u00e8 gi\u00e0 in corso", + "no_devices_found": "Nessun dispositivo trovato in rete", + "not_supported": "Dispositivo non supportato" + }, + "flow_title": "{nome}", + "step": { + "bluetooth_confirm": { + "description": "Vuoi impostare {nome}?" + }, + "user": { + "data": { + "address": "Dispositivo" + }, + "description": "Scegliere un dispositivo da configurare" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/no.json b/homeassistant/components/oralb/translations/no.json new file mode 100644 index 0000000000000000000000000000000000000000..0bf8b1695ec9f4be0208e7d0a9183e6c7dae6bd5 --- /dev/null +++ b/homeassistant/components/oralb/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", + "not_supported": "Enheten st\u00f8ttes ikke" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Vil du konfigurere {name}?" + }, + "user": { + "data": { + "address": "Enhet" + }, + "description": "Velg en enhet du vil konfigurere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/pl.json b/homeassistant/components/oralb/translations/pl.json new file mode 100644 index 0000000000000000000000000000000000000000..4715905a2e9b2f9751cb3d33683f629fed2f2aea --- /dev/null +++ b/homeassistant/components/oralb/translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_in_progress": "Konfiguracja jest ju\u017c w toku", + "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci", + "not_supported": "Urz\u0105dzenie nie jest obs\u0142ugiwane" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {name}?" + }, + "user": { + "data": { + "address": "Urz\u0105dzenie" + }, + "description": "Wybierz urz\u0105dzenie do skonfigurowania" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/pt-BR.json b/homeassistant/components/oralb/translations/pt-BR.json new file mode 100644 index 0000000000000000000000000000000000000000..0da7639fa2a93843aa8bda468ee27aa71d07f405 --- /dev/null +++ b/homeassistant/components/oralb/translations/pt-BR.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "already_in_progress": "A configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", + "no_devices_found": "Nenhum dispositivo encontrado na rede", + "not_supported": "Dispositivo n\u00e3o suportado" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Deseja configurar {name}?" + }, + "user": { + "data": { + "address": "Dispositivo" + }, + "description": "Escolha um dispositivo para configurar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/tr.json b/homeassistant/components/oralb/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..bcccbd0f7eafa8985e7decdf20d28d63d84bd3e8 --- /dev/null +++ b/homeassistant/components/oralb/translations/tr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz \u00e7oktan konfig\u00fcre edilmi\u015ftir", + "already_in_progress": "Konfigurasyon ak\u0131\u015f\u0131 zaten devam ediyor", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131", + "not_supported": "Cihaz desteklenmiyor" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "{name} kurulumunu yapmak ister misiniz?" + }, + "user": { + "data": { + "address": "Cihaz" + }, + "description": "Kurulum i\u00e7in bir cihaz se\u00e7in" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oralb/translations/zh-Hant.json b/homeassistant/components/oralb/translations/zh-Hant.json new file mode 100644 index 0000000000000000000000000000000000000000..64ae1f1909493f69bc0e7076dd6e016bc8236e67 --- /dev/null +++ b/homeassistant/components/oralb/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e", + "not_supported": "\u88dd\u7f6e\u4e0d\u652f\u63f4" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name}\uff1f" + }, + "user": { + "data": { + "address": "\u88dd\u7f6e" + }, + "description": "\u9078\u64c7\u6240\u8981\u8a2d\u5b9a\u7684\u88dd\u7f6e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index d3ab9722fcab829199eb0009abde59e2a9ace46a..eac749f1bc085a64bf240272959be47079eb85e8 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -9,6 +9,7 @@ from pyoverkiz.client import OverkizClient from pyoverkiz.const import SUPPORTED_SERVERS from pyoverkiz.exceptions import ( BadCredentialsException, + CozyTouchBadCredentialsException, MaintenanceException, TooManyAttemptsBannedException, TooManyRequestsException, @@ -67,6 +68,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step via config flow.""" errors = {} + description_placeholders = {} if user_input: self._default_user = user_input[CONF_USERNAME] @@ -76,8 +78,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_validate_input(user_input) except TooManyRequestsException: errors["base"] = "too_many_requests" - except BadCredentialsException: - errors["base"] = "invalid_auth" + except BadCredentialsException as exception: + # If authentication with CozyTouch auth server is valid, but token is invalid + # for Overkiz API server, the hardware is not supported. + if user_input[CONF_HUB] == "atlantic_cozytouch" and not isinstance( + exception, CozyTouchBadCredentialsException + ): + description_placeholders["unsupported_device"] = "CozyTouch" + errors["base"] = "unsupported_hardware" + else: + errors["base"] = "invalid_auth" except (TimeoutError, ClientError): errors["base"] = "cannot_connect" except MaintenanceException: @@ -85,7 +95,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except TooManyAttemptsBannedException: errors["base"] = "too_many_attempts" except UnknownUserException: - errors["base"] = "unknown_user" + # Somfy Protect accounts are not supported since they don't use + # the Overkiz API server. Login will return unknown user. + description_placeholders["unsupported_device"] = "Somfy Protect" + errors["base"] = "unsupported_hardware" except Exception as exception: # pylint: disable=broad-except errors["base"] = "unknown" LOGGER.exception(exception) @@ -129,6 +142,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), } ), + description_placeholders=description_placeholders, errors=errors, ) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 480b0b1d9edfd8900f6940d6fc7e6852b48f90f8..d19495d82a2aca69f45945c18a8c9d5d304ce42c 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -3,7 +3,7 @@ "name": "Overkiz", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/overkiz", - "requirements": ["pyoverkiz==1.5.5"], + "requirements": ["pyoverkiz==1.5.6"], "zeroconf": [ { "type": "_kizbox._tcp.local.", @@ -18,13 +18,5 @@ ], "codeowners": ["@imicknl", "@vlebourl", "@tetienne"], "iot_class": "cloud_polling", - "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "supported_brands": { - "cozytouch": "Atlantic Cozytouch", - "flexom": "Bouygues Flexom", - "hi_kumo": "Hitachi Hi Kumo", - "nexity": "Nexity Eugénie", - "rexel": "Rexel Energeasy Connect", - "somfy": "Somfy" - } + "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"] } diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index 3b40eccfbf6a394b67de32e9c1f6e6914a953b3a..6460e87b4ee9ebca6bba3ba67d86e9165a1df579 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -21,7 +21,6 @@ from .entity import OverkizDescriptiveEntity, OverkizDeviceClass class OverkizSelectDescriptionMixin: """Define an entity description mixin for select entities.""" - options: list[str | OverkizCommandParam] select_option: Callable[[str, Callable[..., Awaitable[None]]], Awaitable[None]] @@ -149,11 +148,6 @@ class OverkizSelect(OverkizDescriptiveEntity, SelectEntity): return None - @property - def options(self) -> list[str]: - """Return a set of selectable options.""" - return self.entity_description.options - async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.select_option( diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index ecc0329eb2a98c52b88f0ca8ac44c5fdf4d6d853..440ed154cfeefa6c11aeca50c498ff47dc6c312b 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -19,7 +19,7 @@ "too_many_attempts": "Too many attempts with an invalid token, temporarily banned", "too_many_requests": "Too many requests, try again later", "unknown": "[%key:common::config_flow::error::unknown%]", - "unknown_user": "Unknown user. Somfy Protect accounts are not supported by this integration." + "unsupported_hardware": "Your {unsupported_device} hardware is not supported by this integration." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", diff --git a/homeassistant/components/overkiz/translations/bg.json b/homeassistant/components/overkiz/translations/bg.json index ff6e8f030308ca800dec5cf1220fc70331d263ed..99fe944f9cb179b99ae9431bea9c3d0978f93b0d 100644 --- a/homeassistant/components/overkiz/translations/bg.json +++ b/homeassistant/components/overkiz/translations/bg.json @@ -11,7 +11,8 @@ "server_in_maintenance": "\u0421\u044a\u0440\u0432\u044a\u0440\u044a\u0442 \u0435 \u0441\u043f\u0440\u044f\u043d \u0437\u0430 \u043f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430", "too_many_requests": "\u0422\u0432\u044a\u0440\u0434\u0435 \u043c\u043d\u043e\u0433\u043e \u0437\u0430\u044f\u0432\u043a\u0438, \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", - "unknown_user": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u0435\u043d \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b. \u0410\u043a\u0430\u0443\u043d\u0442\u0438\u0442\u0435 \u043d\u0430 Somfy Protect \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430\u0442 \u043e\u0442 \u0442\u0430\u0437\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f." + "unknown_user": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u0435\u043d \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b. \u0410\u043a\u0430\u0443\u043d\u0442\u0438\u0442\u0435 \u043d\u0430 Somfy Protect \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430\u0442 \u043e\u0442 \u0442\u0430\u0437\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f.", + "unsupported_hardware": "\u0412\u0430\u0448\u0438\u044f\u0442 \u0445\u0430\u0440\u0434\u0443\u0435\u0440 {unsupported_device} \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 \u043e\u0442 \u0442\u0430\u0437\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f." }, "flow_title": "\u0428\u043b\u044e\u0437: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/ca.json b/homeassistant/components/overkiz/translations/ca.json index ca55a8468b31b716ca6638a2af5d0b338463bcf1..d1707e0cbf2351f04c14576ffe419df51cb332bf 100644 --- a/homeassistant/components/overkiz/translations/ca.json +++ b/homeassistant/components/overkiz/translations/ca.json @@ -12,7 +12,8 @@ "too_many_attempts": "Massa intents amb un 'token' inv\u00e0lid, bloquejat temporalment", "too_many_requests": "Massa sol\u00b7licituds, torna-ho a provar m\u00e9s tard", "unknown": "Error inesperat", - "unknown_user": "Usuari desconegut. Els comptes de Somfy Protect no s\u00f3n compatibles amb aquesta integraci\u00f3." + "unknown_user": "Usuari desconegut. Els comptes de Somfy Protect no s\u00f3n compatibles amb aquesta integraci\u00f3.", + "unsupported_hardware": "{unsupported_device} no \u00e9s compatible amb aquesta integraci\u00f3." }, "flow_title": "Passarel\u00b7la: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/de.json b/homeassistant/components/overkiz/translations/de.json index 1e4cd0cb254f0ccbdcc4990ac90cd6199121caf9..ff7536e036363f334831a36ecaaef4c8f42f36a1 100644 --- a/homeassistant/components/overkiz/translations/de.json +++ b/homeassistant/components/overkiz/translations/de.json @@ -12,7 +12,8 @@ "too_many_attempts": "Zu viele Versuche mit einem ung\u00fcltigen Token, vor\u00fcbergehend gesperrt", "too_many_requests": "Zu viele Anfragen, versuche es sp\u00e4ter erneut.", "unknown": "Unerwarteter Fehler", - "unknown_user": "Unbekannter Benutzer. Somfy Protect-Konten werden von dieser Integration nicht unterst\u00fctzt." + "unknown_user": "Unbekannter Benutzer. Somfy Protect-Konten werden von dieser Integration nicht unterst\u00fctzt.", + "unsupported_hardware": "Deine {unsupported_device} Hardware wird von dieser Integration nicht unterst\u00fctzt." }, "flow_title": "Gateway: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/el.json b/homeassistant/components/overkiz/translations/el.json index e9862479c27cd4a54ece1278c9107eccb0924da9..eb308c470d3351dccc4d758a0f4141fb21e390ae 100644 --- a/homeassistant/components/overkiz/translations/el.json +++ b/homeassistant/components/overkiz/translations/el.json @@ -12,7 +12,8 @@ "too_many_attempts": "\u03a0\u03ac\u03c1\u03b1 \u03c0\u03bf\u03bb\u03bb\u03ad\u03c2 \u03c0\u03c1\u03bf\u03c3\u03c0\u03ac\u03b8\u03b5\u03b9\u03b5\u03c2 \u03bc\u03b5 \u03bc\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf \u03b4\u03b9\u03b1\u03ba\u03c1\u03b9\u03c4\u03b9\u03ba\u03cc, \u03c0\u03c1\u03bf\u03c3\u03c9\u03c1\u03b9\u03bd\u03ac \u03b1\u03c0\u03bf\u03ba\u03bb\u03b5\u03b9\u03c3\u03bc\u03ad\u03bd\u03b5\u03c2", "too_many_requests": "\u03a0\u03ac\u03c1\u03b1 \u03c0\u03bf\u03bb\u03bb\u03ac \u03b1\u03b9\u03c4\u03ae\u03bc\u03b1\u03c4\u03b1, \u03c0\u03c1\u03bf\u03c3\u03c0\u03b1\u03b8\u03ae\u03c3\u03c4\u03b5 \u03be\u03b1\u03bd\u03ac \u03b1\u03c1\u03b3\u03cc\u03c4\u03b5\u03c1\u03b1", "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1", - "unknown_user": "\u0386\u03b3\u03bd\u03c9\u03c3\u03c4\u03bf\u03c2 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7\u03c2. \u039f\u03b9 \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03bf\u03af Somfy Protect \u03b4\u03b5\u03bd \u03c5\u03c0\u03bf\u03c3\u03c4\u03b7\u03c1\u03af\u03b6\u03bf\u03bd\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03b1\u03c5\u03c4\u03ae \u03c4\u03b7\u03bd \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7." + "unknown_user": "\u0386\u03b3\u03bd\u03c9\u03c3\u03c4\u03bf\u03c2 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7\u03c2. \u039f\u03b9 \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03bf\u03af Somfy Protect \u03b4\u03b5\u03bd \u03c5\u03c0\u03bf\u03c3\u03c4\u03b7\u03c1\u03af\u03b6\u03bf\u03bd\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03b1\u03c5\u03c4\u03ae \u03c4\u03b7\u03bd \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7.", + "unsupported_hardware": "\u03a4\u03bf \u03c5\u03bb\u03b9\u03ba\u03cc \u03c3\u03b1\u03c2 {unsupported_device} \u03b4\u03b5\u03bd \u03c5\u03c0\u03bf\u03c3\u03c4\u03b7\u03c1\u03af\u03b6\u03b5\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03b1\u03c5\u03c4\u03ae\u03bd \u03c4\u03b7\u03bd \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7." }, "flow_title": "\u03a0\u03cd\u03bb\u03b7: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/en.json b/homeassistant/components/overkiz/translations/en.json index 9c8ad538695737f6b16c07001f502cb6c2fc6d82..d7dcd2a79ac09d6750a7e4b40c6f71184f3c7f0c 100644 --- a/homeassistant/components/overkiz/translations/en.json +++ b/homeassistant/components/overkiz/translations/en.json @@ -12,7 +12,8 @@ "too_many_attempts": "Too many attempts with an invalid token, temporarily banned", "too_many_requests": "Too many requests, try again later", "unknown": "Unexpected error", - "unknown_user": "Unknown user. Somfy Protect accounts are not supported by this integration." + "unknown_user": "Unknown user. Somfy Protect accounts are not supported by this integration.", + "unsupported_hardware": "Your {unsupported_device} hardware is not supported by this integration." }, "flow_title": "Gateway: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/es.json b/homeassistant/components/overkiz/translations/es.json index a5a3ad16e0dc60a0e3c1346677f099e1ce3f6f20..1cec438abbbaaa39f9863a5233b9acb12729190c 100644 --- a/homeassistant/components/overkiz/translations/es.json +++ b/homeassistant/components/overkiz/translations/es.json @@ -12,7 +12,8 @@ "too_many_attempts": "Demasiados intentos con un token no v\u00e1lido, prohibido temporalmente", "too_many_requests": "Demasiadas solicitudes, vuelve a intentarlo m\u00e1s tarde", "unknown": "Error inesperado", - "unknown_user": "Usuario desconocido. Las cuentas de Somfy Protect no son compatibles con esta integraci\u00f3n." + "unknown_user": "Usuario desconocido. Las cuentas de Somfy Protect no son compatibles con esta integraci\u00f3n.", + "unsupported_hardware": "Tu hardware {unsupported_device} no es compatible con esta integraci\u00f3n." }, "flow_title": "Puerta de enlace: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/et.json b/homeassistant/components/overkiz/translations/et.json index 34639ea1739f925e1e73222466e3244ccef9e4f6..2170fff5c454a3783c196663c16bba186def9cdb 100644 --- a/homeassistant/components/overkiz/translations/et.json +++ b/homeassistant/components/overkiz/translations/et.json @@ -12,7 +12,8 @@ "too_many_attempts": "Liiga palju katseid kehtetu v\u00f5tmega, ajutiselt keelatud", "too_many_requests": "Liiga palju p\u00e4ringuid, proovi hiljem uuesti", "unknown": "Ootamatu t\u00f5rge", - "unknown_user": "Tundmatu kasutaja. See sidumine ei toeta Somfy Protecti kontosid." + "unknown_user": "Tundmatu kasutaja. See sidumine ei toeta Somfy Protecti kontosid.", + "unsupported_hardware": "See sidumine ei toeta {unsupported_device} riistvara." }, "flow_title": "L\u00fc\u00fcs: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/fr.json b/homeassistant/components/overkiz/translations/fr.json index 89d7af10f331658cadde6e4fa5fe543d02974a7d..0fd17d822f56ec5c53f3462ea8f86e871a24f379 100644 --- a/homeassistant/components/overkiz/translations/fr.json +++ b/homeassistant/components/overkiz/translations/fr.json @@ -12,7 +12,8 @@ "too_many_attempts": "Trop de tentatives avec un jeton non valide\u00a0: banni temporairement", "too_many_requests": "Trop de demandes, r\u00e9essayez plus tard.", "unknown": "Erreur inattendue", - "unknown_user": "Utilisateur inconnu. Les comptes Somfy Protect ne sont pas pris en charge par cette int\u00e9gration." + "unknown_user": "Utilisateur inconnu. Les comptes Somfy Protect ne sont pas pris en charge par cette int\u00e9gration.", + "unsupported_hardware": "Votre mat\u00e9riel {unsupported_device} n'est pas pris en charge par cette int\u00e9gration." }, "flow_title": "Passerelle\u00a0: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/hu.json b/homeassistant/components/overkiz/translations/hu.json index 4fa4c0a9ddc551aa3db393c2a51aea57fc21ef85..95e3090add003c2204d7760b82d57078d8cb26df 100644 --- a/homeassistant/components/overkiz/translations/hu.json +++ b/homeassistant/components/overkiz/translations/hu.json @@ -12,7 +12,8 @@ "too_many_attempts": "T\u00fal sok pr\u00f3b\u00e1lkoz\u00e1s \u00e9rv\u00e9nytelen tokennel, ideiglenesen kitiltva", "too_many_requests": "T\u00fal sok a k\u00e9r\u00e9s, pr\u00f3b\u00e1lja meg k\u00e9s\u0151bb \u00fajra.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", - "unknown_user": "Ismeretlen felhaszn\u00e1l\u00f3. Ez az integr\u00e1ci\u00f3 nem t\u00e1mogatja a Somfy Protect fi\u00f3kokat." + "unknown_user": "Ismeretlen felhaszn\u00e1l\u00f3. Ez az integr\u00e1ci\u00f3 nem t\u00e1mogatja a Somfy Protect fi\u00f3kokat.", + "unsupported_hardware": "{unsupported_device} hardver\u00e9t ez az integr\u00e1ci\u00f3 nem t\u00e1mogatja." }, "flow_title": "\u00c1tj\u00e1r\u00f3: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/id.json b/homeassistant/components/overkiz/translations/id.json index 709cef9819ccd13caf0043efe426fa3837666209..8f4b19123663a1f5098815db384ce95dd1c85a15 100644 --- a/homeassistant/components/overkiz/translations/id.json +++ b/homeassistant/components/overkiz/translations/id.json @@ -12,7 +12,8 @@ "too_many_attempts": "Terlalu banyak percobaan dengan token yang tidak valid, untuk sementara diblokir", "too_many_requests": "Terlalu banyak permintaan, coba lagi nanti.", "unknown": "Kesalahan yang tidak diharapkan", - "unknown_user": "Pengguna tidak dikenal. Akun Somfy Protect tidak didukung oleh integrasi ini." + "unknown_user": "Pengguna tidak dikenal. Akun Somfy Protect tidak didukung oleh integrasi ini.", + "unsupported_hardware": "Perangkat keras {unsupported_device} Anda tidak didukung oleh integrasi ini." }, "flow_title": "Gateway: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/it.json b/homeassistant/components/overkiz/translations/it.json index 21602a4cb89c31dcfcfacb86bf9a9dda585131d0..49cc4b9a56723dd27959f6c66573750421606f42 100644 --- a/homeassistant/components/overkiz/translations/it.json +++ b/homeassistant/components/overkiz/translations/it.json @@ -12,7 +12,8 @@ "too_many_attempts": "Troppi tentativi con un token non valido, temporaneamente bandito", "too_many_requests": "Troppe richieste, riprova pi\u00f9 tardi.", "unknown": "Errore imprevisto", - "unknown_user": "Utente sconosciuto. Gli account Somfy Protect non sono supportati da questa integrazione." + "unknown_user": "Utente sconosciuto. Gli account Somfy Protect non sono supportati da questa integrazione.", + "unsupported_hardware": "L'hardware {unsupported_device} non \u00e8 supportato da questa integrazione." }, "flow_title": "Gateway: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/nb.json b/homeassistant/components/overkiz/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/overkiz/translations/nb.json +++ b/homeassistant/components/overkiz/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/overkiz/translations/no.json b/homeassistant/components/overkiz/translations/no.json index 2da02db164d756fd0749ce741c7b6740c26bf0e5..062cf053fbde8fc79ff3b53ec96f9e15f1639330 100644 --- a/homeassistant/components/overkiz/translations/no.json +++ b/homeassistant/components/overkiz/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "reauth_wrong_account": "Du kan bare autentisere denne oppf\u00f8ringen p\u00e5 nytt med samme Overkiz-konto og hub" }, "error": { @@ -12,7 +12,8 @@ "too_many_attempts": "For mange fors\u00f8k med et ugyldig token, midlertidig utestengt", "too_many_requests": "For mange foresp\u00f8rsler. Pr\u00f8v igjen senere", "unknown": "Uventet feil", - "unknown_user": "Ukjent bruker. Somfy Protect-kontoer st\u00f8ttes ikke av denne integrasjonen." + "unknown_user": "Ukjent bruker. Somfy Protect-kontoer st\u00f8ttes ikke av denne integrasjonen.", + "unsupported_hardware": "Maskinvaren din for {unsupported_device} st\u00f8ttes ikke av denne integrasjonen." }, "flow_title": "Gateway: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/pl.json b/homeassistant/components/overkiz/translations/pl.json index 23065776db4f5baffe3ba831de6b3059f27de0b9..517ea42ac47bcd435dddb766863cae1642bec3f9 100644 --- a/homeassistant/components/overkiz/translations/pl.json +++ b/homeassistant/components/overkiz/translations/pl.json @@ -12,7 +12,8 @@ "too_many_attempts": "Zbyt wiele pr\u00f3b z nieprawid\u0142owym tokenem, konto tymczasowo zablokowane", "too_many_requests": "Zbyt wiele \u017c\u0105da\u0144, spr\u00f3buj ponownie p\u00f3\u017aniej.", "unknown": "Nieoczekiwany b\u0142\u0105d", - "unknown_user": "Nieznany u\u017cytkownik. Konta Somfy Protect nie s\u0105 obs\u0142ugiwane przez t\u0119 integracj\u0119." + "unknown_user": "Nieznany u\u017cytkownik. Konta Somfy Protect nie s\u0105 obs\u0142ugiwane przez t\u0119 integracj\u0119.", + "unsupported_hardware": "Twoje urz\u0105dzenie {unsupported_device} nie jest wspierane przez t\u0119 integracj\u0119." }, "flow_title": "Bramka: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/pt-BR.json b/homeassistant/components/overkiz/translations/pt-BR.json index 3e2ff0485609a5f9d864f0d6e3d6b68fc492d7dc..206b863265666bb87927a69a63f126fb5443f701 100644 --- a/homeassistant/components/overkiz/translations/pt-BR.json +++ b/homeassistant/components/overkiz/translations/pt-BR.json @@ -12,7 +12,8 @@ "too_many_attempts": "Muitas tentativas com um token inv\u00e1lido, banido temporariamente", "too_many_requests": "Muitas solicita\u00e7\u00f5es, tente novamente mais tarde", "unknown": "Erro inesperado", - "unknown_user": "Usu\u00e1rio desconhecido. As contas Somfy Protect n\u00e3o s\u00e3o suportadas por esta integra\u00e7\u00e3o." + "unknown_user": "Usu\u00e1rio desconhecido. As contas Somfy Protect n\u00e3o s\u00e3o suportadas por esta integra\u00e7\u00e3o.", + "unsupported_hardware": "Seu hardware {unsupported_device} n\u00e3o \u00e9 compat\u00edvel com esta integra\u00e7\u00e3o." }, "flow_title": "Gateway: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/ru.json b/homeassistant/components/overkiz/translations/ru.json index 17ef0ecd27e28100e5799abbc1e6a29d4b9d3b4e..128792152dcfe233eaf3e55eeb36da653c0a371f 100644 --- a/homeassistant/components/overkiz/translations/ru.json +++ b/homeassistant/components/overkiz/translations/ru.json @@ -12,7 +12,8 @@ "too_many_attempts": "\u0421\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u043f\u043e\u043f\u044b\u0442\u043e\u043a \u0441 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u043c \u0442\u043e\u043a\u0435\u043d\u043e\u043c, \u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u043e.", "too_many_requests": "\u0421\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430.", - "unknown_user": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u0439 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c. \u042d\u0442\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0437\u0430\u043f\u0438\u0441\u0438 Somfy Protect." + "unknown_user": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u0439 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c. \u042d\u0442\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0437\u0430\u043f\u0438\u0441\u0438 Somfy Protect.", + "unsupported_hardware": "{unsupported_device} \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f \u044d\u0442\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0435\u0439." }, "flow_title": "\u0428\u043b\u044e\u0437: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/select.pl.json b/homeassistant/components/overkiz/translations/select.pl.json index 0989aec66fee9d9bf06bb439022b764c2b6bffeb..b925c0f5de6245558e485c364a2c69e96997a646 100644 --- a/homeassistant/components/overkiz/translations/select.pl.json +++ b/homeassistant/components/overkiz/translations/select.pl.json @@ -1,13 +1,13 @@ { "state": { "overkiz__memorized_simple_volume": { - "highest": "Najwy\u017csze", - "standard": "Normalnie" + "highest": "najwy\u017csze", + "standard": "normalnie" }, "overkiz__open_closed_pedestrian": { - "closed": "Zamkni\u0119ta", - "open": "Otwarte", - "pedestrian": "Pieszy" + "closed": "zamkni\u0119ta", + "open": "otwarte", + "pedestrian": "pieszy" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sv.json b/homeassistant/components/overkiz/translations/sv.json index 32565cac512693d109fae28effd27dbc26ca7d7c..2ae1ca66d32a4cceecceebd658536546a559dc8d 100644 --- a/homeassistant/components/overkiz/translations/sv.json +++ b/homeassistant/components/overkiz/translations/sv.json @@ -12,7 +12,8 @@ "too_many_attempts": "F\u00f6r m\u00e5nga f\u00f6rs\u00f6k med en ogiltig token, tillf\u00e4lligt avst\u00e4ngd", "too_many_requests": "F\u00f6r m\u00e5nga f\u00f6rfr\u00e5gningar, f\u00f6rs\u00f6k igen senare", "unknown": "Ov\u00e4ntat fel", - "unknown_user": "Ok\u00e4nd anv\u00e4ndare. Somfy Protect-konton st\u00f6ds inte av denna integration." + "unknown_user": "Ok\u00e4nd anv\u00e4ndare. Somfy Protect-konton st\u00f6ds inte av denna integration.", + "unsupported_hardware": "Din {unsupported_device} h\u00e5rdvara st\u00f6ds inte av den h\u00e4r integrationen." }, "flow_title": "Gateway: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/tr.json b/homeassistant/components/overkiz/translations/tr.json index 3981f7dbc8cb984bb0d35fd82ed8174cfefc50a7..ed82e1386738494b5f2491faa72f3ac5df0e65a6 100644 --- a/homeassistant/components/overkiz/translations/tr.json +++ b/homeassistant/components/overkiz/translations/tr.json @@ -12,7 +12,8 @@ "too_many_attempts": "Ge\u00e7ersiz anahtarla \u00e7ok fazla deneme, ge\u00e7ici olarak yasakland\u0131", "too_many_requests": "\u00c7ok fazla istek var, daha sonra tekrar deneyin", "unknown": "Beklenmeyen hata", - "unknown_user": "Bilinmeyen kullan\u0131c\u0131. Somfy Protect hesaplar\u0131 bu entegrasyon taraf\u0131ndan desteklenmez." + "unknown_user": "Bilinmeyen kullan\u0131c\u0131. Somfy Protect hesaplar\u0131 bu entegrasyon taraf\u0131ndan desteklenmez.", + "unsupported_hardware": "{unsupported_device} donan\u0131m\u0131n\u0131z bu entegrasyon taraf\u0131ndan desteklenmiyor." }, "flow_title": "A\u011f ge\u00e7idi: {gateway_id}", "step": { diff --git a/homeassistant/components/overkiz/translations/zh-Hans.json b/homeassistant/components/overkiz/translations/zh-Hans.json new file mode 100644 index 0000000000000000000000000000000000000000..c2cd756d03f77402e647417a658d54af31e4d439 --- /dev/null +++ b/homeassistant/components/overkiz/translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unsupported_hardware": "\u60a8\u7684\u8bbe\u5907\u786c\u4ef6:\u201c {unsupported_device}\u201d \uff0c\u4e0d\u88ab\u6b64\u96c6\u6210\u6240\u652f\u6301\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/zh-Hant.json b/homeassistant/components/overkiz/translations/zh-Hant.json index c9e20812ecca86256026517cbcc52b317b6884c1..ea8ebcd29dce16e0a0a47010e9f7385eba762207 100644 --- a/homeassistant/components/overkiz/translations/zh-Hant.json +++ b/homeassistant/components/overkiz/translations/zh-Hant.json @@ -12,7 +12,8 @@ "too_many_attempts": "\u4f7f\u7528\u7121\u6548\u6b0a\u6756\u5617\u8a66\u6b21\u6578\u904e\u591a\uff0c\u66ab\u6642\u906d\u5230\u5c01\u9396", "too_many_requests": "\u8acb\u6c42\u6b21\u6578\u904e\u591a\uff0c\u8acb\u7a0d\u5f8c\u91cd\u8a66\u3002", "unknown": "\u672a\u9810\u671f\u932f\u8aa4", - "unknown_user": "\u672a\u77e5\u4f7f\u7528\u8005\u3001\u6b64\u6574\u5408\u4e0d\u652f\u63f4 Somfy Protect \u5e33\u865f\u3002" + "unknown_user": "\u672a\u77e5\u4f7f\u7528\u8005\u3001\u6b64\u6574\u5408\u4e0d\u652f\u63f4 Somfy Protect \u5e33\u865f\u3002", + "unsupported_hardware": "\u6b64\u6574\u5408\u4e0d\u652f\u63f4\u60a8\u7684 {unsupported_device} \u786c\u9ad4\u3002" }, "flow_title": "\u9598\u9053\u5668\uff1a{gateway_id}", "step": { diff --git a/homeassistant/components/ovo_energy/translations/bg.json b/homeassistant/components/ovo_energy/translations/bg.json index 9b0d9f27ccb8da1614ef3a2337f845d1c772a3bf..b0c9e8a77cc5a4f6798f439658b8d4492e0afaa2 100644 --- a/homeassistant/components/ovo_energy/translations/bg.json +++ b/homeassistant/components/ovo_energy/translations/bg.json @@ -11,7 +11,7 @@ "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430" }, - "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" }, "user": { "data": { diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 79efee6bd2ca9e7f8bc62d4ad392ac4aae1bd726..b65ac9ccbe7eb55594c58a6d482a502ea2e6b7f0 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -49,8 +49,7 @@ async def async_setup_entry( hass.data[OT_DOMAIN]["context"].set_async_see(_receive_data) - if entities: - async_add_entities(entities) + async_add_entities(entities) class OwnTracksEntity(TrackerEntity, RestoreEntity): diff --git a/homeassistant/components/owntracks/translations/de.json b/homeassistant/components/owntracks/translations/de.json index f3a7542cdd596d5bf5e11da1800c3c5970728e3d..b755c152be44d82ab66493b2bc25abab3b7892c4 100644 --- a/homeassistant/components/owntracks/translations/de.json +++ b/homeassistant/components/owntracks/translations/de.json @@ -5,7 +5,7 @@ "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "create_entry": { - "default": "Unter Android \u00f6ffne [die OwnTracks App]({android_url}), gehe zu Einstellungen \u2192 Verbindung. \u00c4ndere die folgenden Einstellungen:\n - Modus: Privat HTTP\n - Host: {webhook_url}\n - Identifikation:\n - Benutzername: `'<Your name>'`\n - Ger\u00e4te-ID: `'<Your device name>'`\n\nUnter iOS \u00f6ffne [die OwnTracks App]({ios_url}), tippe auf das (i)-Symbol oben links \u2192 Einstellungen. \u00c4ndere die folgenden Einstellungen:\n - Modus: HTTP\n - URL: {webhook_url}\n - Authentifizierung einschalten\n - UserID: `'<Your name>'`\n\n{secret}\n\nWeitere Informationen findest du in [der Dokumentation]({docs_url})." + "default": "Unter Android \u00f6ffne [die OwnTracks App]({android_url}), gehe zu Einstellungen \u2192 Verbindung. \u00c4ndere die folgenden Einstellungen:\n - Modus: Privat HTTP\n - Host: {webhook_url}\n - Identifikation:\n - Benutzername: `'<Your name>'`\n - Ger\u00e4te-ID: `'<Your device name>'`\n\nUnter iOS \u00f6ffne [die OwnTracks App]({ios_url}), tippe auf das (i)-Symbol oben links \u2192 Einstellungen. \u00c4ndere die folgenden Einstellungen:\n - Modus: HTTP\n - URL: {webhook_url}\n - Authentifizierung einschalten\n - UserID: `'<Your name>'`\n\n{secret}\n\nWeitere Informationen findest du in [der Dokumentation]({docs_url})." }, "step": { "user": { diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index e55c8dacea5b0767267c69659b3cf6c8a3f33948..8a1c9d68b5a6c851ef3b4111453e7a81b084b4c1 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -222,13 +222,14 @@ SENSORS_WATERMETER: tuple[SensorEntityDescription, ...] = ( name="Consumption Day", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=VOLUME_LITERS, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.WATER, ), SensorEntityDescription( key="consumption_total", name="Consumption Total", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=SensorDeviceClass.WATER, ), SensorEntityDescription( key="pulse_count", diff --git a/homeassistant/components/panasonic_viera/translations/nb.json b/homeassistant/components/panasonic_viera/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/panasonic_viera/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/pcs_lighting/manifest.json b/homeassistant/components/pcs_lighting/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..3655032e270364a6ad750540f89db7158a6bc374 --- /dev/null +++ b/homeassistant/components/pcs_lighting/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "pcs_lighting", + "name": "PCS Lighting", + "integration_type": "virtual", + "supported_by": "upb" +} diff --git a/homeassistant/components/persistent_notification/manifest.json b/homeassistant/components/persistent_notification/manifest.json index c21e8150d8a81cd67f7b68f7dcc06ab0b2c1a1cb..c60746e35b168da420b052275519c27e95426cec 100644 --- a/homeassistant/components/persistent_notification/manifest.json +++ b/homeassistant/components/persistent_notification/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/persistent_notification", "codeowners": ["@home-assistant/core"], "quality_scale": "internal", - "iot_class": "local_push" + "iot_class": "local_push", + "integration_type": "system" } diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 86a132027d8bc67eca902e760f7a4031e9c2c11d..a469a459acee0dc18941d39cb301d0d23374b6eb 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol @@ -546,8 +547,10 @@ class Person(collection.CollectionEntity, RestoreEntity): @websocket_api.websocket_command({vol.Required(CONF_TYPE): "person/list"}) def ws_list_person( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg -): + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """List persons.""" yaml, storage, _ = hass.data[DOMAIN] connection.send_result( diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 29c8ab36ba2fe5215100fe367d8e5d50a681cf80..a31212be3f7676195f45cdd8c7c689eb4bc2b682 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -7,7 +7,7 @@ from datetime import timedelta import logging from typing import Any -from haphilipsjs import ConnectionFailure, PhilipsTV +from haphilipsjs import AutenticationFailure, ConnectionFailure, PhilipsTV from haphilipsjs.typing import SystemType from homeassistant.config_entries import ConfigEntry @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HassJob, HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.trigger import TriggerActionType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN @@ -169,7 +169,11 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _notify_task(self): while self._notify_wanted: - res = await self.api.notifyChange(130) + try: + res = await self.api.notifyChange(130) + except (ConnectionFailure, AutenticationFailure): + res = None + if res: self.async_set_updated_data(None) elif res is None: @@ -203,3 +207,5 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): self._async_notify_schedule() except ConnectionFailure: pass + except AutenticationFailure as exception: + raise UpdateFailed(str(exception)) from exception diff --git a/homeassistant/components/philips_js/translations/nb.json b/homeassistant/components/philips_js/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/philips_js/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/picnic/translations/bg.json b/homeassistant/components/picnic/translations/bg.json index aaf9f767fff47f19cd608d8e443f2db44103c824..24fa035f619cfc46332ccd9d837b8d56bf5c615c 100644 --- a/homeassistant/components/picnic/translations/bg.json +++ b/homeassistant/components/picnic/translations/bg.json @@ -5,13 +5,16 @@ "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": { - "country_code": "\u041a\u043e\u0434 \u043d\u0430 \u0434\u044a\u0440\u0436\u0430\u0432\u0430\u0442\u0430" + "country_code": "\u041a\u043e\u0434 \u043d\u0430 \u0434\u044a\u0440\u0436\u0430\u0432\u0430\u0442\u0430", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" } } } diff --git a/homeassistant/components/picnic/translations/nb.json b/homeassistant/components/picnic/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/picnic/translations/nb.json +++ b/homeassistant/components/picnic/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/picnic/translations/no.json b/homeassistant/components/picnic/translations/no.json index 1ebb4b8decedeacac30a9585473b49e1e6c37cea..eeb6f5178022f0e93d4282709eae9f84f0fa80fa 100644 --- a/homeassistant/components/picnic/translations/no.json +++ b/homeassistant/components/picnic/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index e79b7e7ee04b65e03c7d40d1c075c7ad035858c9..1ebe439ff7cc872ebf74c1815264de4dd9dc9947 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -106,6 +106,7 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.token = None self.client_id = None self._manual = False + self._reauth_config = None async def async_step_user(self, user_input=None, errors=None): """Handle a flow initialized by the user.""" @@ -178,6 +179,9 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_server_validate(self, server_config): """Validate a provided configuration.""" + if self._reauth_config: + server_config = {**self._reauth_config, **server_config} + errors = {} self.current_login = server_config @@ -336,7 +340,9 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" - self.current_login = dict(entry_data) + self._reauth_config = { + CONF_SERVER_IDENTIFIER: entry_data[CONF_SERVER_IDENTIFIER] + } return await self.async_step_user() diff --git a/homeassistant/components/plex/translations/nb.json b/homeassistant/components/plex/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/plex/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/translations/no.json b/homeassistant/components/plex/translations/no.json index c34b4b1c257bc2922a148c5960df6033cb1b90df..3f504ac89c4044525d195fb3c47f3a81c2681255 100644 --- a/homeassistant/components/plex/translations/no.json +++ b/homeassistant/components/plex/translations/no.json @@ -4,7 +4,7 @@ "all_configured": "Alle knyttet servere som allerede er konfigurert", "already_configured": "Denne Plex-serveren er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "token_request_timeout": "Tidsavbrudd ved innhenting av token", "unknown": "Uventet feil" }, diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 6f751b82b35038ef97c6e0ff42b26cdf0b63ef83..164135a607b508545066175136b91e856f66fae7 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -29,6 +29,13 @@ class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( + PlugwiseBinarySensorEntityDescription( + key="compressor_state", + name="Compressor state", + icon="mdi:hvac", + icon_off="mdi:hvac-off", + entity_category=EntityCategory.DIAGNOSTIC, + ), PlugwiseBinarySensorEntityDescription( key="dhw_state", name="DHW state", diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 84dc4576700e144acddd9ce3b794f1547df3740a..8d0d3578c2c9a8648ef343958eea2bfea990873d 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -5,6 +5,8 @@ from collections.abc import Mapping from typing import Any from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -52,8 +54,12 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self._attr_extra_state_attributes = {} self._attr_unique_id = f"{device_id}-climate" - # Determine preset modes + # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + if self.coordinator.data.gateway["cooling_present"]: + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) if presets := self.device.get("preset_modes"): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = presets @@ -61,7 +67,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): # Determine hvac modes and current hvac mode self._attr_hvac_modes = [HVACMode.HEAT] if self.coordinator.data.gateway["cooling_present"]: - self._attr_hvac_modes.append(HVACMode.COOL) + self._attr_hvac_modes = [HVACMode.HEAT_COOL] if self.device["available_schedules"] != ["None"]: self._attr_hvac_modes.append(HVACMode.AUTO) @@ -79,12 +85,32 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): @property def target_temperature(self) -> float: - """Return the temperature we try to reach.""" + """Return the temperature we try to reach. + + Connected to the HVACMode combination of AUTO-HEAT. + """ + return self.device["thermostat"]["setpoint"] + @property + def target_temperature_high(self) -> float: + """Return the temperature we try to reach in case of cooling. + + Connected to the HVACMode combination of AUTO-HEAT_COOL. + """ + return self.device["thermostat"]["setpoint_high"] + + @property + def target_temperature_low(self) -> float: + """Return the heating temperature we try to reach in case of heating. + + Connected to the HVACMode combination AUTO-HEAT_COOL. + """ + return self.device["thermostat"]["setpoint_low"] + @property def hvac_mode(self) -> HVACMode: - """Return HVAC operation ie. heat, cool mode.""" + """Return HVAC operation ie. auto, heat, or heat_cool mode.""" if (mode := self.device.get("mode")) is None or mode not in self.hvac_modes: return HVACMode.HEAT return HVACMode(mode) @@ -127,12 +153,21 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): @plugwise_command async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if ((temperature := kwargs.get(ATTR_TEMPERATURE)) is None) or not ( - self._attr_min_temp <= temperature <= self._attr_max_temp - ): - raise ValueError("Invalid temperature change requested") - - await self.coordinator.api.set_temperature(self.device["location"], temperature) + data: dict[str, Any] = {} + if ATTR_TEMPERATURE in kwargs: + data["setpoint"] = kwargs.get(ATTR_TEMPERATURE) + if ATTR_TARGET_TEMP_HIGH in kwargs: + data["setpoint_high"] = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if ATTR_TARGET_TEMP_LOW in kwargs: + data["setpoint_low"] = kwargs.get(ATTR_TARGET_TEMP_LOW) + + for temperature in data.values(): + if temperature is None or not ( + self._attr_min_temp <= temperature <= self._attr_max_temp + ): + raise ValueError("Invalid temperature change requested") + + await self.coordinator.api.set_temperature(self.device["location"], data) @plugwise_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index 8cf2456c0b4415f81ea49974995ad684bd159538..09b919e9d1d4fbb7741ce89a237474167ae25475 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -41,20 +41,22 @@ from .const import ( ) -def _base_gw_schema(discovery_info): +def _base_gw_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema: """Generate base schema for gateways.""" - base_gw_schema = {} + base_gw_schema = vol.Schema({vol.Required(CONF_PASSWORD): str}) if not discovery_info: - base_gw_schema[vol.Required(CONF_HOST)] = str - base_gw_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int - base_gw_schema[vol.Required(CONF_USERNAME, default=SMILE)] = vol.In( - {SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH} + base_gw_schema = base_gw_schema.extend( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_USERNAME, default=SMILE): vol.In( + {SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH} + ), + } ) - base_gw_schema.update({vol.Required(CONF_PASSWORD): str}) - - return vol.Schema(base_gw_schema) + return base_gw_schema async def validate_gw_input(hass: HomeAssistant, data: dict[str, Any]) -> Smile: diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index c2d0d75c8a046c6ca49ef3628960ce9509ec55bb..c1f759622fa831a76b5d1c3aa54f970686815972 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -31,8 +31,8 @@ PLATFORMS_GATEWAY: Final[list[str]] = [ Platform.SWITCH, ] ZEROCONF_MAP: Final[dict[str, str]] = { - "smile": "P1", - "smile_thermo": "Anna", + "smile": "Smile P1", + "smile_thermo": "Smile Anna", "smile_open_therm": "Adam", "stretch": "Stretch", } diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 1c8de0c6544347fa81d04828c42f6a1671114da7..6fd44efda84bd5441c215a090a4ee4e70bc3752d 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -1,8 +1,9 @@ """DataUpdateCoordinator for Plugwise.""" from datetime import timedelta -from typing import Any, NamedTuple +from typing import NamedTuple, cast from plugwise import Smile +from plugwise.constants import DeviceData, GatewayData from plugwise.exceptions import PlugwiseException, XMLDataMissingError from homeassistant.core import HomeAssistant @@ -15,8 +16,8 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER class PlugwiseData(NamedTuple): """Plugwise data stored in the DataUpdateCoordinator.""" - gateway: dict[str, Any] - devices: dict[str, dict[str, Any]] + gateway: GatewayData + devices: dict[str, DeviceData] class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): @@ -52,4 +53,7 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): ) from err except PlugwiseException as err: raise UpdateFailed(f"Updated failed for: {self.api.smile_name}") from err - return PlugwiseData(*data) + return PlugwiseData( + gateway=cast(GatewayData, data[0]), + devices=cast(dict[str, DeviceData], data[1]), + ) diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index 694f6e5817c6dc910a9dc89a25718f14abe015ac..e2ab5445f07ba239b7090f8700c12b2dff74ee9e 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -1,7 +1,7 @@ """Generic Plugwise Entity Class.""" from __future__ import annotations -from typing import Any +from plugwise.constants import DeviceData from homeassistant.const import ATTR_NAME, ATTR_VIA_DEVICE, CONF_HOST from homeassistant.helpers.device_registry import ( @@ -65,10 +65,14 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self._dev_id in self.coordinator.data.devices + return ( + self._dev_id in self.coordinator.data.devices + and ("available" not in self.device or self.device["available"]) + and super().available + ) @property - def device(self) -> dict[str, Any]: + def device(self) -> DeviceData: """Return data for this device.""" return self.coordinator.data.devices[self._dev_id] diff --git a/homeassistant/components/plugwise/gateway.py b/homeassistant/components/plugwise/gateway.py index 4fde6a54a4a410f386e375617f5d323a9fc64a28..16b6a9775691a56346832e564e0fef1f3c2a384b 100644 --- a/homeassistant/components/plugwise/gateway.py +++ b/homeassistant/components/plugwise/gateway.py @@ -72,8 +72,8 @@ async def async_setup_entry_gw(hass: HomeAssistant, entry: ConfigEntry) -> bool: config_entry_id=entry.entry_id, identifiers={(DOMAIN, str(api.gateway_id))}, manufacturer="Plugwise", - name=entry.title, - model=f"Smile {api.smile_name}", + model=api.smile_model, + name=api.smile_name, sw_version=api.smile_version[0], ) @@ -82,7 +82,7 @@ async def async_setup_entry_gw(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry_gw(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry_gw(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms( entry, PLATFORMS_GATEWAY diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 1b17f3e49f55977ffb246435d1f0ecaf0bc165e1..7f3e979ab7d3d0ee283964a5a8a3ec122c7069f4 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -2,10 +2,11 @@ "domain": "plugwise", "name": "Plugwise", "documentation": "https://www.home-assistant.io/integrations/plugwise", - "requirements": ["plugwise==0.21.4"], + "requirements": ["plugwise==0.25.3"], "codeowners": ["@CoMPaTech", "@bouwew", "@brefra", "@frenck"], "zeroconf": ["_plugwise._tcp.local."], "config_flow": true, "iot_class": "local_polling", + "integration_type": "hub", "loggers": ["crcmod", "plugwise"] } diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 989f56adcf32123cd35de12b3311beec35e11193..73157c6a962d447d3a1b106dad6c377b87914617 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -52,6 +52,17 @@ SELECT_TYPES = ( command=lambda api, loc, opt: api.set_regulation_mode(opt), current_option_key="regulation_mode", options_key="regulation_modes", + device_class="plugwise__regulation_mode", + ), + PlugwiseSelectEntityDescription( + key="select_dhw_mode", + name="DHW mode", + icon="mdi:shower", + entity_category=EntityCategory.CONFIG, + command=lambda api, loc, opt: api.set_dhw_mode(opt), + current_option_key="dhw_mode", + options_key="dhw_modes", + device_class="plugwise__dhw_mode", ), ) diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 3ee0437e8ddf43fae74e80d758fb968682008faf..b6e2a3aa413eb84abaa2fb5c89a0dbd62eb692d0 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, UNIT_LUMEN @@ -31,12 +32,30 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="setpoint_high", + name="Cooling setpoint", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="setpoint_low", + name="Heating setpoint", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="temperature", name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -44,6 +63,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( name="Intended boiler temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -51,6 +71,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( name="Temperature difference", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -65,6 +86,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( name="Outdoor air temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -72,6 +94,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( name="Water temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -79,6 +102,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( name="Return temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -94,6 +118,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=POWER_WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, ), SensorEntityDescription( key="electricity_consumed_interval", @@ -122,6 +147,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=ENERGY_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, ), SensorEntityDescription( key="electricity_produced_peak_interval", @@ -226,6 +252,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -240,12 +267,14 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( name="Modulation level", icon="mdi:percent", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="valve_position", name="Valve position", icon="mdi:valve", + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), @@ -254,6 +283,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( name="Water pressure", native_unit_of_measurement=PRESSURE_BAR, device_class=SensorDeviceClass.PRESSURE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -263,6 +293,14 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + key="dhw_temperature", + name="DHW temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), ) diff --git a/homeassistant/components/plugwise/strings.select.json b/homeassistant/components/plugwise/strings.select.json new file mode 100644 index 0000000000000000000000000000000000000000..92098e3bc3585a1097e1f5fb50a29837fcdc8d96 --- /dev/null +++ b/homeassistant/components/plugwise/strings.select.json @@ -0,0 +1,17 @@ +{ + "state": { + "plugwise__regulation_mode": { + "bleeding_cold": "Bleeding cold", + "bleeding_hot": "Bleeding hot", + "cooling": "Cooling", + "heating": "Heating", + "off": "Off" + }, + "plugwise__dhw_mode": { + "off": "Off", + "auto": "Auto", + "boost": "Boost", + "comfort": "Comfort" + } + } +} diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index c2942308b7501bbdbb11e195871b20193006f297..a8e5597efbf9d96d6383e2c01812f902904684c9 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -36,6 +36,12 @@ SWITCHES: tuple[SwitchEntityDescription, ...] = ( name="Relay", device_class=SwitchDeviceClass.SWITCH, ), + SwitchEntityDescription( + key="cooling_ena_switch", + name="Cooling", + icon="mdi:snowflake-thermometer", + entity_category=EntityCategory.CONFIG, + ), ) diff --git a/homeassistant/components/plugwise/translations/nb.json b/homeassistant/components/plugwise/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/plugwise/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.bg.json b/homeassistant/components/plugwise/translations/select.bg.json new file mode 100644 index 0000000000000000000000000000000000000000..f27035d8884abdfcbeb548a67e0c0892644bf97c --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.bg.json @@ -0,0 +1,12 @@ +{ + "state": { + "plugwise__dhw_mode": { + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d" + }, + "plugwise__regulation_mode": { + "cooling": "\u041e\u0445\u043b\u0430\u0436\u0434\u0430\u043d\u0435", + "heating": "\u041e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u0435", + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.ca.json b/homeassistant/components/plugwise/translations/select.ca.json new file mode 100644 index 0000000000000000000000000000000000000000..a5b699c36f15d2bbb9b1744a30892179626a2b12 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.ca.json @@ -0,0 +1,17 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "Autom\u00e0tic", + "boost": "Incrementat", + "comfort": "Confort", + "off": "OFF" + }, + "plugwise__regulation_mode": { + "bleeding_cold": "Molt fred", + "bleeding_hot": "Molt calent", + "cooling": "Refredant", + "heating": "Escalfant", + "off": "OFF" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.de.json b/homeassistant/components/plugwise/translations/select.de.json new file mode 100644 index 0000000000000000000000000000000000000000..9ee193d6e0e01b72ae086b7932c94ddd62b28ac5 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.de.json @@ -0,0 +1,17 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "Automatisch", + "boost": "Boost", + "comfort": "Komfort", + "off": "Aus" + }, + "plugwise__regulation_mode": { + "bleeding_cold": "Kalt", + "bleeding_hot": "Hei\u00df", + "cooling": "K\u00fchlung", + "heating": "Heizbetrieb", + "off": "Aus" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.el.json b/homeassistant/components/plugwise/translations/select.el.json new file mode 100644 index 0000000000000000000000000000000000000000..88f8e117b480a7a9fe30a7b8a47af965077a90bb --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.el.json @@ -0,0 +1,11 @@ +{ + "state": { + "plugwise__regulation_mode": { + "bleeding_cold": "\u0391\u03b9\u03bc\u03bf\u03c1\u03c1\u03b1\u03b3\u03af\u03b1 \u03ba\u03c1\u03cd\u03b1", + "bleeding_hot": "\u0391\u03b9\u03bc\u03bf\u03c1\u03c1\u03b1\u03b3\u03af\u03b1 \u03ba\u03b1\u03c5\u03c4\u03ae", + "cooling": "\u03a8\u03cd\u03be\u03b7", + "heating": "\u0398\u03ad\u03c1\u03bc\u03b1\u03bd\u03c3\u03b7", + "off": "\u0391\u03bd\u03b5\u03bd\u03b5\u03c1\u03b3\u03cc" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.en.json b/homeassistant/components/plugwise/translations/select.en.json new file mode 100644 index 0000000000000000000000000000000000000000..150222c28785af2a93ad59b4f49c3006fcee9480 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.en.json @@ -0,0 +1,17 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "Auto", + "boost": "Boost", + "comfort": "Comfort", + "off": "Off" + }, + "plugwise__regulation_mode": { + "bleeding_cold": "Bleeding cold", + "bleeding_hot": "Bleeding hot", + "cooling": "Cooling", + "heating": "Heating", + "off": "Off" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.es.json b/homeassistant/components/plugwise/translations/select.es.json new file mode 100644 index 0000000000000000000000000000000000000000..09de452fda321dbd4d99807c5bd4af96885ac518 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.es.json @@ -0,0 +1,17 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "Autom\u00e1tico", + "boost": "Impulso", + "comfort": "Confort", + "off": "Apagado" + }, + "plugwise__regulation_mode": { + "bleeding_cold": "Purgando fr\u00edo", + "bleeding_hot": "Purgando caliente", + "cooling": "Refrigeraci\u00f3n", + "heating": "Calefacci\u00f3n", + "off": "Apagado" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.et.json b/homeassistant/components/plugwise/translations/select.et.json new file mode 100644 index 0000000000000000000000000000000000000000..10605bb3ce9ce8985426fc9bb185ce2905851a60 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.et.json @@ -0,0 +1,17 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "Automaatne", + "boost": "Turbo", + "comfort": "Mugavus", + "off": "V\u00e4ljas" + }, + "plugwise__regulation_mode": { + "bleeding_cold": "Jahutuseat soenemine", + "bleeding_hot": "K\u00fcttest jahtumine", + "cooling": "Jahutamine", + "heating": "K\u00fcte", + "off": "V\u00e4ljas" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.fr.json b/homeassistant/components/plugwise/translations/select.fr.json new file mode 100644 index 0000000000000000000000000000000000000000..169a4bd3a2a9b0a29a38ae9c02109c08547e9750 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.fr.json @@ -0,0 +1,10 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "Auto", + "boost": "Boost", + "comfort": "Confort", + "off": "Arr\u00eat" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.hu.json b/homeassistant/components/plugwise/translations/select.hu.json new file mode 100644 index 0000000000000000000000000000000000000000..18480d95f63846a822e3c9dda03ae5e979460570 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.hu.json @@ -0,0 +1,17 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "Automatikus", + "boost": "Turb\u00f3", + "comfort": "Komfort", + "off": "Ki" + }, + "plugwise__regulation_mode": { + "bleeding_cold": "J\u00e9ghideg", + "bleeding_hot": "Forr\u00f3", + "cooling": "H\u0171t\u00e9s", + "heating": "F\u0171t\u00e9s", + "off": "Ki" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.id.json b/homeassistant/components/plugwise/translations/select.id.json new file mode 100644 index 0000000000000000000000000000000000000000..0be50360a0fa4d5cd99a76ada7fd3f8db8fb87c6 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.id.json @@ -0,0 +1,11 @@ +{ + "state": { + "plugwise__regulation_mode": { + "bleeding_cold": "Dingin sekali", + "bleeding_hot": "Panas sekali", + "cooling": "Mendinginkan", + "heating": "Memanaskan", + "off": "Mati" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.it.json b/homeassistant/components/plugwise/translations/select.it.json new file mode 100644 index 0000000000000000000000000000000000000000..97106b6c5b92dc1eea7835531d5959174c24da31 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.it.json @@ -0,0 +1,17 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "Automatico", + "boost": "Velocizza", + "comfort": "Comfort", + "off": "Spento" + }, + "plugwise__regulation_mode": { + "bleeding_cold": "Sfiatamento freddo", + "bleeding_hot": "Sfiatamento caldo", + "cooling": "Raffreddamento", + "heating": "Riscaldamento", + "off": "Spento" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.ja.json b/homeassistant/components/plugwise/translations/select.ja.json new file mode 100644 index 0000000000000000000000000000000000000000..581f4ca36a3ff637bdad8e45f7e7086f42be56b8 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.ja.json @@ -0,0 +1,9 @@ +{ + "state": { + "plugwise__regulation_mode": { + "cooling": "\u51b7\u623f(\u51b7\u5374)", + "heating": "\u6696\u623f(\u52a0\u71b1)", + "off": "\u30aa\u30d5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.nl.json b/homeassistant/components/plugwise/translations/select.nl.json new file mode 100644 index 0000000000000000000000000000000000000000..546719dee724e6b2115ee8abf7dc229d16c513d1 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.nl.json @@ -0,0 +1,11 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "Automatisch", + "off": "Uit" + }, + "plugwise__regulation_mode": { + "off": "Uit" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.no.json b/homeassistant/components/plugwise/translations/select.no.json new file mode 100644 index 0000000000000000000000000000000000000000..4907badc6f84df5960f5d32a4c0df531873b4a63 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.no.json @@ -0,0 +1,17 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "Auto", + "boost": "\u00d8ke", + "comfort": "Komfort", + "off": "Av" + }, + "plugwise__regulation_mode": { + "bleeding_cold": "Bl\u00f8dende kaldt", + "bleeding_hot": "Bl\u00f8dende varmt", + "cooling": "Kj\u00f8ling", + "heating": "Oppvarming", + "off": "Av" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.pl.json b/homeassistant/components/plugwise/translations/select.pl.json new file mode 100644 index 0000000000000000000000000000000000000000..d17e5788de04f1efbdf385f675e34c6501aa91e3 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.pl.json @@ -0,0 +1,17 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "auto", + "boost": "dogrzewanie", + "comfort": "komfortowo", + "off": "wy\u0142\u0105czone" + }, + "plugwise__regulation_mode": { + "bleeding_cold": "strasznie zimno", + "bleeding_hot": "strasznie ciep\u0142o", + "cooling": "ch\u0142odzenie", + "heating": "grzanie", + "off": "wy\u0142." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.pt-BR.json b/homeassistant/components/plugwise/translations/select.pt-BR.json new file mode 100644 index 0000000000000000000000000000000000000000..4804fca07e2fb2e54746bfdefa48f99c10ac1495 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.pt-BR.json @@ -0,0 +1,17 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "Autom\u00e1tico", + "boost": "Impulso", + "comfort": "Conforto", + "off": "Desligado" + }, + "plugwise__regulation_mode": { + "bleeding_cold": "Frio congelante", + "bleeding_hot": "Queimando quente", + "cooling": "Resfriamento", + "heating": "Aquecimento", + "off": "Desligado" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.ru.json b/homeassistant/components/plugwise/translations/select.ru.json new file mode 100644 index 0000000000000000000000000000000000000000..9330a9e435cf5ad0d9540e6cbb072bbc56a3172f --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.ru.json @@ -0,0 +1,15 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438", + "boost": "\u0422\u0443\u0440\u0431\u043e", + "comfort": "\u041a\u043e\u043c\u0444\u043e\u0440\u0442", + "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + }, + "plugwise__regulation_mode": { + "cooling": "\u041e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "heating": "\u041e\u0431\u043e\u0433\u0440\u0435\u0432", + "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.sv.json b/homeassistant/components/plugwise/translations/select.sv.json new file mode 100644 index 0000000000000000000000000000000000000000..3667fd8a09a91bdbfb25f7b7bd62eadf043bec45 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.sv.json @@ -0,0 +1,11 @@ +{ + "state": { + "plugwise__regulation_mode": { + "bleeding_cold": "Riktigt kallt", + "bleeding_hot": "Riktigt varmt", + "cooling": "Kylning", + "heating": "V\u00e4rme", + "off": "Av" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.tr.json b/homeassistant/components/plugwise/translations/select.tr.json new file mode 100644 index 0000000000000000000000000000000000000000..9ae8b443ebd90741ad5f384858e73af456d6bc94 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.tr.json @@ -0,0 +1,11 @@ +{ + "state": { + "plugwise__regulation_mode": { + "bleeding_cold": "So\u011futma", + "bleeding_hot": "Is\u0131tma", + "cooling": "So\u011futma", + "heating": "Is\u0131tma", + "off": "Kapal\u0131" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.zh-Hans.json b/homeassistant/components/plugwise/translations/select.zh-Hans.json new file mode 100644 index 0000000000000000000000000000000000000000..d454e38b89b20245406970d7f2180d0bda851f77 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.zh-Hans.json @@ -0,0 +1,11 @@ +{ + "state": { + "plugwise__regulation_mode": { + "bleeding_cold": "\u8fc7\u51b7", + "bleeding_hot": "\u8fc7\u70ed", + "cooling": "\u5236\u51b7", + "heating": "\u5236\u70ed", + "off": "\u5173" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/select.zh-Hant.json b/homeassistant/components/plugwise/translations/select.zh-Hant.json new file mode 100644 index 0000000000000000000000000000000000000000..fac75962cfac84e118375cd95d707a09fb4984f4 --- /dev/null +++ b/homeassistant/components/plugwise/translations/select.zh-Hant.json @@ -0,0 +1,17 @@ +{ + "state": { + "plugwise__dhw_mode": { + "auto": "\u81ea\u52d5", + "boost": "\u5168\u901f\u6a21\u5f0f", + "comfort": "\u8212\u9069\u6a21\u5f0f", + "off": "\u95dc\u9589" + }, + "plugwise__regulation_mode": { + "bleeding_cold": "\u5f37\u51b7", + "bleeding_hot": "\u5f37\u6696", + "cooling": "\u51b7\u6c23", + "heating": "\u6696\u6c23", + "off": "\u95dc\u9589" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py index f990efc3fccb1cf63455559e95e7c027f0f98abd..770570a3c393fe4e661f09dedb381657dd2d1ed4 100644 --- a/homeassistant/components/plum_lightpad/light.py +++ b/homeassistant/components/plum_lightpad/light.py @@ -42,8 +42,7 @@ async def async_setup_entry( logical_load = plum.get_load(device["llid"]) entities.append(PlumLight(load=logical_load)) - if entities: - async_add_entities(entities) + async_add_entities(entities) async def new_load(device): setup_entities(device) diff --git a/homeassistant/components/plum_lightpad/translations/bg.json b/homeassistant/components/plum_lightpad/translations/bg.json index 597f4d3616562c4dca16c8d2c06fec43242c5f61..ba64157f26978d4918ca803ad85cbdd554fac230 100644 --- a/homeassistant/components/plum_lightpad/translations/bg.json +++ b/homeassistant/components/plum_lightpad/translations/bg.json @@ -10,7 +10,7 @@ "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "Email" + "username": "\u0418\u043c\u0435\u0439\u043b" } } } diff --git a/homeassistant/components/poolsense/translations/bg.json b/homeassistant/components/poolsense/translations/bg.json index eb033e74f0fb52096a1a59e027077492204c9194..713631893791ffdc38bd4358b82ec6a6bbfa8eb8 100644 --- a/homeassistant/components/poolsense/translations/bg.json +++ b/homeassistant/components/poolsense/translations/bg.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "email": "Email", + "email": "\u0418\u043c\u0435\u0439\u043b", "password": "\u041f\u0430\u0440\u043e\u043b\u0430" } } diff --git a/homeassistant/components/powerwall/translations/nb.json b/homeassistant/components/powerwall/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/powerwall/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/powerwall/translations/no.json b/homeassistant/components/powerwall/translations/no.json index 01cf58c6ed4ca9e7adc505e00eabf7598d99a011..b6d6c05149096ab25bf7882a165d426477fe4cd5 100644 --- a/homeassistant/components/powerwall/translations/no.json +++ b/homeassistant/components/powerwall/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Enheten er allerede konfigurert", "cannot_connect": "Tilkobling mislyktes", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/progettihwsw/translations/nb.json b/homeassistant/components/progettihwsw/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/progettihwsw/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/nb.json b/homeassistant/components/prosegur/translations/nb.json index c106bc179b3179a230b87df09a2ae93f2af3c927..65b9b20886d9c7db26fbded5bd67694dd47da469 100644 --- a/homeassistant/components/prosegur/translations/nb.json +++ b/homeassistant/components/prosegur/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "reauth_confirm": { "data": { diff --git a/homeassistant/components/prosegur/translations/no.json b/homeassistant/components/prosegur/translations/no.json index 73bacd26c14cdd4c1a0e39f980626f26d815fee0..6e553ffa1c63b1ffead98cf5be592ecd5d78f786 100644 --- a/homeassistant/components/prosegur/translations/no.json +++ b/homeassistant/components/prosegur/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/prusalink/translations/he.json b/homeassistant/components/prusalink/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..31a1d4ade8234b4fc86ade5e2658da9bbed71366 --- /dev/null +++ b/homeassistant/components/prusalink/translations/he.json @@ -0,0 +1,17 @@ +{ + "config": { + "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": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "host": "\u05de\u05d0\u05e8\u05d7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prusalink/translations/nb.json b/homeassistant/components/prusalink/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/prusalink/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prusalink/translations/sensor.nl.json b/homeassistant/components/prusalink/translations/sensor.nl.json index 0dfc3902f680e9ed255dc32aa6891422d364099d..2e874b25f5aea19dbba6f61084edb953564502e7 100644 --- a/homeassistant/components/prusalink/translations/sensor.nl.json +++ b/homeassistant/components/prusalink/translations/sensor.nl.json @@ -4,6 +4,7 @@ "cancelling": "Annuleren", "idle": "Inactief", "paused": "Gepauzeerd", + "pausing": "Pauzeren", "printing": "Afdrukken" } } diff --git a/homeassistant/components/pushover/__init__.py b/homeassistant/components/pushover/__init__.py index 3c0c92db04436225d4ce3ba927c3c1a0c7f87bb8..551e374fbb644ba91e8af5856f8575ee41b87aa2 100644 --- a/homeassistant/components/pushover/__init__.py +++ b/homeassistant/components/pushover/__init__.py @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: pushover_api.validate, entry.data[CONF_USER_KEY] ) - except BadAPIRequestError as err: + except (BadAPIRequestError, ValueError) as err: if "application token is invalid" in str(err): raise ConfigEntryAuthFailed(err) from err raise ConfigEntryNotReady(err) from err diff --git a/homeassistant/components/pushover/config_flow.py b/homeassistant/components/pushover/config_flow.py index ddb61d4bbc3aec894f301ed8ad2604aa840ce625..5119d91a174ac5c7a5bc34cf126642ccbe67a397 100644 --- a/homeassistant/components/pushover/config_flow.py +++ b/homeassistant/components/pushover/config_flow.py @@ -44,10 +44,6 @@ class PushBulletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _reauth_entry: config_entries.ConfigEntry | None - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Handle import from config.""" - return await self.async_step_user(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( diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py index bcf472641088db0af1456b1ddda4f2af8e47a0c1..fa4b35da2faac1d1c419903395b3f6905783c0be 100644 --- a/homeassistant/components/pushover/notify.py +++ b/homeassistant/components/pushover/notify.py @@ -15,7 +15,6 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -57,18 +56,11 @@ async def async_get_service( async_create_issue( hass, DOMAIN, - "deprecated_yaml", + "removed_yaml", breaks_in_ha_version="2022.11.0", is_fixable=False, severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) + translation_key="removed_yaml", ) return None @@ -89,7 +81,7 @@ class PushoverNotificationService(BaseNotificationService): self._user_key = user_key self.pushover = pushover - def send_message(self, message: str = "", **kwargs: dict[str, Any]) -> None: + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" # Extract params from data dict diff --git a/homeassistant/components/pushover/strings.json b/homeassistant/components/pushover/strings.json index c309a1ec01fce51b89c9679444a2c2d5809d9d57..3c2ab66bf399ee9d37c1f69b9d2e88b71746e364 100644 --- a/homeassistant/components/pushover/strings.json +++ b/homeassistant/components/pushover/strings.json @@ -26,9 +26,9 @@ } }, "issues": { - "deprecated_yaml": { - "title": "The Pushover YAML configuration is being removed", - "description": "Configuring Pushover using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Pushover YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + "removed_yaml": { + "title": "The Pushover YAML configuration has been removed", + "description": "Configuring Pushover using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the Pushover YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." } } } diff --git a/homeassistant/components/pushover/translations/ca.json b/homeassistant/components/pushover/translations/ca.json index 1a14a4ce3d51ac788de3db5f022072a31bf5ff53..cbfc95c86ba1f374653e477823721b1db83a2672 100644 --- a/homeassistant/components/pushover/translations/ca.json +++ b/homeassistant/components/pushover/translations/ca.json @@ -29,6 +29,10 @@ "deprecated_yaml": { "description": "La configuraci\u00f3 de Pushover mitjan\u00e7ant YAML s'eliminar\u00e0 de Home Assistant.\n\nLa configuraci\u00f3 YAML existent s'ha importat autom\u00e0ticament a la interf\u00edcie d'usuari.\n\nElimina la configuraci\u00f3 YAML de Pushover del fitxer configuration.yaml i reinicia Home Assistant per solucionar aquest problema.", "title": "La configuraci\u00f3 YAML de Pushover est\u00e0 sent eliminada" + }, + "removed_yaml": { + "description": "La configuraci\u00f3 de Pushover mitjan\u00e7ant YAML s'ha eliminat de Home Assistant.\n\nHome Assistant ja no utilitza la configuraci\u00f3 YAML existent.\n\nElimina la configuraci\u00f3 YAML de Pushover del fitxer configuration.yaml i reinicia Home Assistant per solucionar aquest problema.", + "title": "La configuraci\u00f3 YAML de Pushover s'ha eliminat" } } } \ No newline at end of file diff --git a/homeassistant/components/pushover/translations/de.json b/homeassistant/components/pushover/translations/de.json index 9a5aa0cb5716c6e8fbfe92ae008780ea3ccf20dc..1a99ef663fae50c54ac0d7e2d8efc8d02b7405d5 100644 --- a/homeassistant/components/pushover/translations/de.json +++ b/homeassistant/components/pushover/translations/de.json @@ -29,6 +29,10 @@ "deprecated_yaml": { "description": "Das Konfigurieren von Pushover mit YAML wird entfernt. \n\nDeine vorhandene YAML-Konfiguration wurde automatisch in die Benutzeroberfl\u00e4che importiert. \n\nEntferne die Pushover-YAML-Konfiguration aus deiner configuration.yaml-Datei und starte Home Assistant neu, um dieses Problem zu beheben.", "title": "Die Pushover-YAML-Konfiguration wird entfernt" + }, + "removed_yaml": { + "description": "Das Konfigurieren von Pushover mit YAML wurde entfernt. \n\nDeine vorhandene YAML-Konfiguration wird von Home Assistant nicht verwendet. \n\nEntferne die Pushover-YAML-Konfiguration aus deiner configuration.yaml-Datei und starte Home Assistant neu, um dieses Problem zu beheben.", + "title": "Die Pushover-YAML-Konfiguration wurde entfernt" } } } \ No newline at end of file diff --git a/homeassistant/components/pushover/translations/en.json b/homeassistant/components/pushover/translations/en.json index 33826000dc3ae0e1bd9c76a461619c58b125095a..4a126782ddaf317199b15265eda77a69535d84ae 100644 --- a/homeassistant/components/pushover/translations/en.json +++ b/homeassistant/components/pushover/translations/en.json @@ -29,6 +29,10 @@ "deprecated_yaml": { "description": "Configuring Pushover using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Pushover YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", "title": "The Pushover YAML configuration is being removed" + }, + "removed_yaml": { + "description": "Configuring Pushover using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the Pushover YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", + "title": "The Pushover YAML configuration has been removed" } } } \ No newline at end of file diff --git a/homeassistant/components/pushover/translations/es.json b/homeassistant/components/pushover/translations/es.json index e28f7045aa0dff3687ea7e4fb08063d246e67988..418fe68c28e87d1e7dfd23f2d3d0907d0e86deb7 100644 --- a/homeassistant/components/pushover/translations/es.json +++ b/homeassistant/components/pushover/translations/es.json @@ -29,6 +29,10 @@ "deprecated_yaml": { "description": "Se va a eliminar la configuraci\u00f3n de Pushover mediante YAML. \n\nTu configuraci\u00f3n YAML existente se ha importado a la IU autom\u00e1ticamente. \n\nElimina la configuraci\u00f3n YAML de Pushover de tu archivo configuration.yaml y reinicia Home Assistant para solucionar este problema.", "title": "Se va a eliminar la configuraci\u00f3n YAML de Pushover" + }, + "removed_yaml": { + "description": "Se ha eliminado la configuraci\u00f3n de Pushover mediante YAML. \n\nHome Assistant no utiliza tu configuraci\u00f3n YAML existente. \n\nElimina la configuraci\u00f3n Pushover YAML de tu archivo configuration.yaml y reinicia Home Assistant para solucionar este problema.", + "title": "Se ha eliminado la configuraci\u00f3n YAML de Pushover" } } } \ No newline at end of file diff --git a/homeassistant/components/pushover/translations/et.json b/homeassistant/components/pushover/translations/et.json index 1d309198b5c519783bad04761fbbb31bc4d6d836..7ae9c0abbf1ef9a7aee8a53d954368eb4158573a 100644 --- a/homeassistant/components/pushover/translations/et.json +++ b/homeassistant/components/pushover/translations/et.json @@ -29,6 +29,10 @@ "deprecated_yaml": { "description": "Pushoveri seadistamine YAML-i abil eemaldatakse.\n\nTeie olemasolev YAML-i konfiguratsioon imporditakse kasutajaliidesesse automaatselt.\n\nEemaldage failist configuration.yaml-i pushover-konfiguratsioon ja taask\u00e4ivitage selle probleemi lahendamiseks Home Assistant.", "title": "Pushover YAML konfiguratsioon eemaldatakse" + }, + "removed_yaml": { + "description": "Pushoveri konfigureerimine YAML-i abil on eemaldatud. \n\n Koduassistent ei kasuta teie olemasolevat YAML-i konfiguratsiooni. \n\n Selle probleemi lahendamiseks eemaldage Pushoveri YAML-i konfiguratsioon failist configuration.yaml ja taask\u00e4ivitage Home Assistant.", + "title": "Pushoveri YAML-i konfiguratsioon on eemaldatud" } } } \ No newline at end of file diff --git a/homeassistant/components/pushover/translations/he.json b/homeassistant/components/pushover/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..9cdb8c5afcd913c125da1f247a64b439cac6fecb --- /dev/null +++ b/homeassistant/components/pushover/translations/he.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/pushover/translations/id.json b/homeassistant/components/pushover/translations/id.json index 077ee9a450bf984f2f59f2784143df7ff61290a1..347f4ef5d3e3f1ae6caa60bafeeb9c8816fdbfb3 100644 --- a/homeassistant/components/pushover/translations/id.json +++ b/homeassistant/components/pushover/translations/id.json @@ -27,8 +27,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Proses konfigurasi Pushover lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Pushover dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Pushover dalam proses penghapusan" + "description": "Proses konfigurasi Integrasi Pushover lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Integrasi Pushover dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Pushover dalam proses penghapusan" } } } \ No newline at end of file diff --git a/homeassistant/components/pushover/translations/it.json b/homeassistant/components/pushover/translations/it.json index c72f62b0c211843f1e73a2d09dc3d9eac4eafdb6..8eec84415b7d77c0d0265c213ca7a01ff42bb941 100644 --- a/homeassistant/components/pushover/translations/it.json +++ b/homeassistant/components/pushover/translations/it.json @@ -29,6 +29,9 @@ "deprecated_yaml": { "description": "La configurazione di Pushover tramite YAML sar\u00e0 rimossa.\n\nLa configurazione YAML esistente \u00e8 stata importata automaticamente nell'interfaccia utente.\n\nRimuovi la configurazione YAML di Pushover dal file configuration.yaml e riavvia Home Assistant per risolvere questo problema.", "title": "La configurazione YAML di Pushover sar\u00e0 rimossa" + }, + "removed_yaml": { + "title": "La configurazione Pushover YAML \u00e8 stata rimossa" } } } \ No newline at end of file diff --git a/homeassistant/components/pushover/translations/no.json b/homeassistant/components/pushover/translations/no.json index 32f733eedcbe75f7ab8914fd9fba77df6cc99a85..dd8713b73a4f21625351f5ac9fecd06b9414439f 100644 --- a/homeassistant/components/pushover/translations/no.json +++ b/homeassistant/components/pushover/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Tjenesten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", @@ -29,6 +29,10 @@ "deprecated_yaml": { "description": "Konfigurering av Pushover med YAML blir fjernet. \n\n Din eksisterende YAML-konfigurasjon har blitt importert til brukergrensesnittet automatisk. \n\n Fjern Pushover YAML-konfigurasjonen fra configuration.yaml-filen og start Home Assistant p\u00e5 nytt for \u00e5 fikse dette problemet.", "title": "Pushover YAML-konfigurasjonen blir fjernet" + }, + "removed_yaml": { + "description": "Konfigurering av Pushover med YAML er fjernet. \n\n Din eksisterende YAML-konfigurasjon brukes ikke av Home Assistant. \n\n Fjern Pushover YAML-konfigurasjonen fra configuration.yaml-filen og start Home Assistant p\u00e5 nytt for \u00e5 fikse dette problemet.", + "title": "Pushover YAML-konfigurasjonen er fjernet" } } } \ No newline at end of file diff --git a/homeassistant/components/pushover/translations/pl.json b/homeassistant/components/pushover/translations/pl.json index 02195af9229975fc0e77cc45ef673fefc4f17221..46b3596dd1cb404694716a4977ac36489f9b3019 100644 --- a/homeassistant/components/pushover/translations/pl.json +++ b/homeassistant/components/pushover/translations/pl.json @@ -29,6 +29,10 @@ "deprecated_yaml": { "description": "Konfiguracja Pushover przy u\u017cyciu YAML zostanie usuni\u0119ta. \n\nTwoja istniej\u0105ca konfiguracja YAML zosta\u0142a automatycznie zaimportowana do interfejsu u\u017cytkownika. \n\nUsu\u0144 konfiguracj\u0119 YAML z pliku configuration.yaml i uruchom ponownie Home Assistanta, aby rozwi\u0105za\u0107 ten problem.", "title": "Konfiguracja YAML dla Pushover zostanie usuni\u0119ta" + }, + "removed_yaml": { + "description": "Konfiguracja Pushover za pomoc\u0105 YAML zosta\u0142a usuni\u0119ta. \n\nTwoja istniej\u0105ca konfiguracja YAML nie jest u\u017cywana przez Home Assistant. \n\nUsu\u0144 konfiguracj\u0119 YAML z pliku configuration.yaml i uruchom ponownie Home Assistanta, aby rozwi\u0105za\u0107 ten problem.", + "title": "Konfiguracja YAML dla Pushover zosta\u0142a usuni\u0119ta" } } } \ No newline at end of file diff --git a/homeassistant/components/pushover/translations/pt-BR.json b/homeassistant/components/pushover/translations/pt-BR.json index 60f2cb1b3b28c83f1bb254d2296f531815754f35..6e2f6412602cc994d83e41a09c39dfe6584878ac 100644 --- a/homeassistant/components/pushover/translations/pt-BR.json +++ b/homeassistant/components/pushover/translations/pt-BR.json @@ -29,6 +29,10 @@ "deprecated_yaml": { "description": "A configura\u00e7\u00e3o do Pushover usando YAML est\u00e1 sendo removida. \n\n Sua configura\u00e7\u00e3o YAML existente foi importada para a interface do usu\u00e1rio automaticamente. \n\n Remova a configura\u00e7\u00e3o Pushover YAML do arquivo configuration.yaml e reinicie o Home Assistant para corrigir esse problema.", "title": "A configura\u00e7\u00e3o do Pushover YAML est\u00e1 sendo removida" + }, + "removed_yaml": { + "description": "A configura\u00e7\u00e3o do Pushover usando YAML foi removida. \n\n Sua configura\u00e7\u00e3o YAML existente n\u00e3o \u00e9 usada pelo Home Assistant. \n\n Remova a configura\u00e7\u00e3o Pushover YAML do arquivo configuration.yaml e reinicie o Home Assistant para corrigir esse problema.", + "title": "A configura\u00e7\u00e3o YAML do Pushover foi removida" } } } \ No newline at end of file diff --git a/homeassistant/components/pushover/translations/tr.json b/homeassistant/components/pushover/translations/tr.json index d91471736b11630c65bdd2acad287ac1622de5d4..5ab133b70a9e06403806af293fb662040ba09e46 100644 --- a/homeassistant/components/pushover/translations/tr.json +++ b/homeassistant/components/pushover/translations/tr.json @@ -29,6 +29,10 @@ "deprecated_yaml": { "description": "YAML kullanarak Pushover'\u0131 yap\u0131land\u0131rma kald\u0131r\u0131l\u0131yor. \n\n Mevcut YAML yap\u0131land\u0131rman\u0131z otomatik olarak kullan\u0131c\u0131 aray\u00fcz\u00fcne aktar\u0131ld\u0131. \n\n Pushover YAML yap\u0131land\u0131rmas\u0131n\u0131 configuration.yaml dosyan\u0131zdan kald\u0131r\u0131n ve bu sorunu gidermek i\u00e7in Home Assistant'\u0131 yeniden ba\u015flat\u0131n.", "title": "Pushover YAML yap\u0131land\u0131rmas\u0131 kald\u0131r\u0131l\u0131yor" + }, + "removed_yaml": { + "description": "Pushover'i YAML kullanarak yap\u0131land\u0131rma kald\u0131r\u0131ld\u0131.\n\nMevcut YAML yap\u0131land\u0131rman\u0131z Home Assistant taraf\u0131ndan kullan\u0131lmaz.\n\nYAML yap\u0131land\u0131rmas\u0131n\u0131 configuration.yaml dosyan\u0131zdan kald\u0131r\u0131n ve bu sorunu gidermek i\u00e7in Home Assistant'\u0131 yeniden ba\u015flat\u0131n.", + "title": "Pushover YAML yap\u0131land\u0131rmas\u0131 kald\u0131r\u0131l\u0131yor" } } } \ No newline at end of file diff --git a/homeassistant/components/pushover/translations/zh-Hant.json b/homeassistant/components/pushover/translations/zh-Hant.json index bf359028bde689f531f88cf679cb27a9acfccfe1..82f4f8f2d943e9c13932199ec6183e7762060042 100644 --- a/homeassistant/components/pushover/translations/zh-Hant.json +++ b/homeassistant/components/pushover/translations/zh-Hant.json @@ -29,6 +29,9 @@ "deprecated_yaml": { "description": "\u4f7f\u7528 YAML \u8a2d\u5b9a\u7684 Pushover \u5373\u5c07\u9032\u884c\u79fb\u9664\u3002\n\n\u65e2\u6709\u7684 YAML \u8a2d\u5b9a\u5c07\u81ea\u52d5\u532f\u5165\u81f3 UI \u5167\u3002\n\n\u8acb\u65bc configuration.yaml \u6a94\u6848\u4e2d\u79fb\u9664 Pushover YAML \u8a2d\u5b9a\u4e26\u91cd\u65b0\u555f\u52d5 Home Assistant \u4ee5\u4fee\u6b63\u6b64\u554f\u984c\u3002", "title": "Pushover YAML \u8a2d\u5b9a\u5373\u5c07\u79fb\u9664" + }, + "removed_yaml": { + "description": "\u4f7f\u7528 YAML \u8a2d\u5b9a Pushover \u7684\u529f\u80fd\u5373\u5c07\u79fb\u9664\u3002\n\nHome Assistant \u5c07\u4e0d\u518d\u4f7f\u7528\u73fe\u6709\u7684 YAML \u8a2d\u5b9a\u3002\n\n\u7531 configuration.yaml \u6a94\u6848\u4e2d\u79fb\u9664 Pushover YAML \u8a2d\u5b9a\u4e26\u91cd\u555f Home Assistant \u4ee5\u4fee\u6b63\u6b64\u554f\u984c\u3002" } } } \ No newline at end of file diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index 947238df8337e711825b3d9c737009b4f33f3ddf..3a77e5b2ddf9ac2b663dbbf9be266e660ed8684f 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -6,5 +6,6 @@ "codeowners": ["@frenck"], "requirements": ["pvo==0.2.2"], "iot_class": "cloud_polling", - "quality_scale": "platinum" + "quality_scale": "platinum", + "integration_type": "device" } diff --git a/homeassistant/components/pvoutput/translations/no.json b/homeassistant/components/pvoutput/translations/no.json index 899483c7adb07076c0a0f50d81ffccc44a247c82..6f64f05bcd840e1e8a27b59493c947d1227694c3 100644 --- a/homeassistant/components/pvoutput/translations/no.json +++ b/homeassistant/components/pvoutput/translations/no.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/qingping/binary_sensor.py b/homeassistant/components/qingping/binary_sensor.py index cdc75af0b09e8f127a393af50510b8d498226c0c..046792a2ff227dd0b587817d1dae18da6717b29c 100644 --- a/homeassistant/components/qingping/binary_sensor.py +++ b/homeassistant/components/qingping/binary_sensor.py @@ -39,6 +39,10 @@ BINARY_SENSOR_DESCRIPTIONS = { key=QingpingBinarySensorDeviceClass.DOOR, device_class=BinarySensorDeviceClass.DOOR, ), + QingpingBinarySensorDeviceClass.PROBLEM: BinarySensorEntityDescription( + key=QingpingBinarySensorDeviceClass.PROBLEM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), } diff --git a/homeassistant/components/qingping/manifest.json b/homeassistant/components/qingping/manifest.json index 85df751bfc7c799225dd59088507effb17bf00e7..31657280b199aa3b9f03f565ec63527ca1a79470 100644 --- a/homeassistant/components/qingping/manifest.json +++ b/homeassistant/components/qingping/manifest.json @@ -11,7 +11,7 @@ "connectable": false } ], - "requirements": ["qingping-ble==0.7.0"], + "requirements": ["qingping-ble==0.8.2"], "dependencies": ["bluetooth"], "codeowners": ["@bdraco", "@skgsergio"], "iot_class": "local_push" diff --git a/homeassistant/components/qingping/translations/he.json b/homeassistant/components/qingping/translations/he.json index 47308062d0d426cb13dd9e46494762b2e48d2482..de780eb221ab27344e979ccb6033be518ec02711 100644 --- a/homeassistant/components/qingping/translations/he.json +++ b/homeassistant/components/qingping/translations/he.json @@ -1,5 +1,10 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea" + }, "flow_title": "{name}", "step": { "bluetooth_confirm": { diff --git a/homeassistant/components/qingping/translations/hu.json b/homeassistant/components/qingping/translations/hu.json index 140271f7840c33f4f11ce45dba91df2bf0baf808..7913c9946c0aa274526b9edd0b6aa338d3bc0bc0 100644 --- a/homeassistant/components/qingping/translations/hu.json +++ b/homeassistant/components/qingping/translations/hu.json @@ -15,7 +15,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lasszon ki egy eszk\u00f6zt a be\u00e1ll\u00edt\u00e1shoz" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/qnap_qsw/translations/bg.json b/homeassistant/components/qnap_qsw/translations/bg.json index 33ce2a4028f7d1d60419a534f294c235d2d3c807..091a2f4466dc4028f53dd0968bc2bf4065cd23c8 100644 --- a/homeassistant/components/qnap_qsw/translations/bg.json +++ b/homeassistant/components/qnap_qsw/translations/bg.json @@ -8,6 +8,12 @@ "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": { + "discovered_connection": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", diff --git a/homeassistant/components/rachio/translations/nb.json b/homeassistant/components/rachio/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/rachio/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 5e32f64b7ad94d8198853130fcbc82a0a8fd4658..403bedda94a4a5c541780a459e25e653d03f8050 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -1,6 +1,8 @@ """The Radarr component.""" from __future__ import annotations +from typing import Any, cast + from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient @@ -29,6 +31,7 @@ from .coordinator import ( MoviesDataUpdateCoordinator, RadarrDataUpdateCoordinator, StatusDataUpdateCoordinator, + T, ) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -65,7 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host_configuration=host_configuration, session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) - coordinators: dict[str, RadarrDataUpdateCoordinator] = { + coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = { "status": StatusDataUpdateCoordinator(hass, host_configuration, radarr), "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), "health": HealthDataUpdateCoordinator(hass, host_configuration, radarr), @@ -86,15 +89,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class RadarrEntity(CoordinatorEntity[RadarrDataUpdateCoordinator]): +class RadarrEntity(CoordinatorEntity[RadarrDataUpdateCoordinator[T]]): """Defines a base Radarr entity.""" _attr_has_entity_name = True - coordinator: RadarrDataUpdateCoordinator + coordinator: RadarrDataUpdateCoordinator[T] def __init__( self, - coordinator: RadarrDataUpdateCoordinator, + coordinator: RadarrDataUpdateCoordinator[T], description: EntityDescription, ) -> None: """Create Radarr entity.""" @@ -113,5 +116,7 @@ class RadarrEntity(CoordinatorEntity[RadarrDataUpdateCoordinator]): name=self.coordinator.config_entry.title, ) if isinstance(self.coordinator, StatusDataUpdateCoordinator): - device_info[ATTR_SW_VERSION] = self.coordinator.data.version + device_info[ATTR_SW_VERSION] = cast( + StatusDataUpdateCoordinator, self.coordinator + ).data.version return device_info diff --git a/homeassistant/components/radarr/binary_sensor.py b/homeassistant/components/radarr/binary_sensor.py index 2a1a729e6f415f8fa352db6c1e18fcbb47b5b879..3952a694e94d5eeed57eeb32a843d128026ca5b8 100644 --- a/homeassistant/components/radarr/binary_sensor.py +++ b/homeassistant/components/radarr/binary_sensor.py @@ -1,6 +1,8 @@ """Support for Radarr binary sensors.""" from __future__ import annotations +from aiopyarr import Health + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -32,7 +34,7 @@ async def async_setup_entry( async_add_entities([RadarrBinarySensor(coordinator, BINARY_SENSOR_TYPE)]) -class RadarrBinarySensor(RadarrEntity, BinarySensorEntity): +class RadarrBinarySensor(RadarrEntity[list[Health]], BinarySensorEntity): """Implementation of a Radarr binary sensor.""" @property diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index 06ea32e790fe70c1962b57954b0efaa810de6785..dfcd1e3a269b38ddb5d460424c6979498a4fa251 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -3,9 +3,9 @@ from __future__ import annotations from abc import abstractmethod from datetime import timedelta -from typing import Generic, TypeVar, cast +from typing import Generic, TypeVar, Union, cast -from aiopyarr import Health, RootFolder, SystemStatus, exceptions +from aiopyarr import Health, RadarrMovie, RootFolder, SystemStatus, exceptions from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient @@ -16,10 +16,10 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER -T = TypeVar("T", SystemStatus, list[RootFolder], list[Health], int) +T = TypeVar("T", bound=Union[SystemStatus, list[RootFolder], list[Health], int]) -class RadarrDataUpdateCoordinator(DataUpdateCoordinator, Generic[T]): +class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T]): """Data update coordinator for the Radarr integration.""" config_entry: ConfigEntry @@ -58,7 +58,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator, Generic[T]): raise NotImplementedError -class StatusDataUpdateCoordinator(RadarrDataUpdateCoordinator): +class StatusDataUpdateCoordinator(RadarrDataUpdateCoordinator[SystemStatus]): """Status update coordinator for Radarr.""" async def _fetch_data(self) -> SystemStatus: @@ -66,15 +66,15 @@ class StatusDataUpdateCoordinator(RadarrDataUpdateCoordinator): return await self.api_client.async_get_system_status() -class DiskSpaceDataUpdateCoordinator(RadarrDataUpdateCoordinator): +class DiskSpaceDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[RootFolder]]): """Disk space update coordinator for Radarr.""" async def _fetch_data(self) -> list[RootFolder]: """Fetch the data.""" - return cast(list, await self.api_client.async_get_root_folders()) + return cast(list[RootFolder], await self.api_client.async_get_root_folders()) -class HealthDataUpdateCoordinator(RadarrDataUpdateCoordinator): +class HealthDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[Health]]): """Health update coordinator.""" async def _fetch_data(self) -> list[Health]: @@ -82,9 +82,9 @@ class HealthDataUpdateCoordinator(RadarrDataUpdateCoordinator): return await self.api_client.async_get_failed_health_checks() -class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator): +class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]): """Movies update coordinator.""" async def _fetch_data(self) -> int: """Fetch the movies data.""" - return len(cast(list, await self.api_client.async_get_movies())) + return len(cast(list[RadarrMovie], await self.api_client.async_get_movies())) diff --git a/homeassistant/components/radarr/manifest.json b/homeassistant/components/radarr/manifest.json index 5bc15b24069cd65bebc5689e2c34e118239ee735..9b140def96ad12626f78b1e86ab55af91089661e 100644 --- a/homeassistant/components/radarr/manifest.json +++ b/homeassistant/components/radarr/manifest.json @@ -2,7 +2,7 @@ "domain": "radarr", "name": "Radarr", "documentation": "https://www.home-assistant.io/integrations/radarr", - "requirements": ["aiopyarr==22.9.0"], + "requirements": ["aiopyarr==22.10.0"], "codeowners": ["@tkdrob"], "config_flow": true, "iot_class": "local_polling", diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index e424844c6022150e6427986d45cdce81f1e04cd5..27d1a5487a2fd27887ee69e93820e2f900a1c4af 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -4,10 +4,10 @@ from __future__ import annotations from collections.abc import Callable from copy import deepcopy from dataclasses import dataclass -from datetime import timezone -from typing import Generic +from datetime import datetime, timezone +from typing import Any, Generic -from aiopyarr import Diskspace, RootFolder +from aiopyarr import Diskspace, RootFolder, SystemStatus import voluptuous as vol from homeassistant.components.sensor import ( @@ -32,7 +32,7 @@ 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, StateType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import RadarrEntity from .const import DOMAIN @@ -50,8 +50,8 @@ def get_space(data: list[Diskspace], name: str) -> str: def get_modified_description( - description: RadarrSensorEntityDescription, mount: RootFolder -) -> tuple[RadarrSensorEntityDescription, str]: + description: RadarrSensorEntityDescription[T], mount: RootFolder +) -> tuple[RadarrSensorEntityDescription[T], str]: """Return modified description and folder name.""" desc = deepcopy(description) name = mount.path.rsplit("/")[-1].rsplit("\\")[-1] @@ -64,7 +64,7 @@ def get_modified_description( class RadarrSensorEntityDescriptionMixIn(Generic[T]): """Mixin for required keys.""" - value_fn: Callable[[T, str], str] + value_fn: Callable[[T, str], str | int | datetime] @dataclass @@ -74,12 +74,12 @@ class RadarrSensorEntityDescription( """Class to describe a Radarr sensor.""" description_fn: Callable[ - [RadarrSensorEntityDescription, RootFolder], - tuple[RadarrSensorEntityDescription, str] | None, - ] = lambda _, __: None + [RadarrSensorEntityDescription[T], RootFolder], + tuple[RadarrSensorEntityDescription[T], str] | None, + ] | None = None -SENSOR_TYPES: dict[str, RadarrSensorEntityDescription] = { +SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = { "disk_space": RadarrSensorEntityDescription( key="disk_space", name="Disk space", @@ -88,7 +88,7 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription] = { value_fn=get_space, description_fn=get_modified_description, ), - "movie": RadarrSensorEntityDescription( + "movie": RadarrSensorEntityDescription[int]( key="movies", name="Movies", native_unit_of_measurement="Movies", @@ -96,7 +96,7 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription] = { entity_registry_enabled_default=False, value_fn=lambda data, _: data, ), - "status": RadarrSensorEntityDescription( + "status": RadarrSensorEntityDescription[SystemStatus]( key="start_time", name="Start time", device_class=SensorDeviceClass.TIMESTAMP, @@ -152,10 +152,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Radarr sensors based on a config entry.""" - coordinators: dict[str, RadarrDataUpdateCoordinator] = hass.data[DOMAIN][ + coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = hass.data[DOMAIN][ entry.entry_id ] - entities = [] + entities: list[RadarrSensor[Any]] = [] for coordinator_type, description in SENSOR_TYPES.items(): coordinator = coordinators[coordinator_type] if coordinator_type != "disk_space": @@ -169,16 +169,16 @@ async def async_setup_entry( async_add_entities(entities) -class RadarrSensor(RadarrEntity, SensorEntity): +class RadarrSensor(RadarrEntity[T], SensorEntity): """Implementation of the Radarr sensor.""" - coordinator: RadarrDataUpdateCoordinator - entity_description: RadarrSensorEntityDescription + coordinator: RadarrDataUpdateCoordinator[T] + entity_description: RadarrSensorEntityDescription[T] def __init__( self, - coordinator: RadarrDataUpdateCoordinator, - description: RadarrSensorEntityDescription, + coordinator: RadarrDataUpdateCoordinator[T], + description: RadarrSensorEntityDescription[T], folder_name: str = "", ) -> None: """Create Radarr entity.""" @@ -186,6 +186,6 @@ class RadarrSensor(RadarrEntity, SensorEntity): self.folder_name = folder_name @property - def native_value(self) -> StateType: + def native_value(self) -> str | int | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data, self.folder_name) diff --git a/homeassistant/components/radarr/translations/bg.json b/homeassistant/components/radarr/translations/bg.json index 5ce0eec5412ecb4d1831c2f011b7611f70a1f413..562883a2f23925e523ec9eef46a9ecd5c8d3e2cf 100644 --- a/homeassistant/components/radarr/translations/bg.json +++ b/homeassistant/components/radarr/translations/bg.json @@ -7,7 +7,8 @@ "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" + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430", + "zeroconf_failed": "API \u043a\u043b\u044e\u0447\u044a\u0442 \u043d\u0435 \u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d. \u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0433\u043e \u0440\u044a\u0447\u043d\u043e." }, "step": { "reauth_confirm": { @@ -21,6 +22,15 @@ } } }, + "issues": { + "deprecated_yaml": { + "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435\u0442\u043e \u043d\u0430 Radarr \u0441 \u043f\u043e\u043c\u043e\u0449\u0442\u0430 \u043d\u0430 YAML \u0441\u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u0432\u0430.\n\n\u0412\u0430\u0448\u0430\u0442\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430 YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u0435 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u0430\u043d\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e \u0432 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u0438\u044f \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441.\n\n\u041f\u0440\u0435\u043c\u0430\u0445\u043d\u0435\u0442\u0435 YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 Radarr \u043e\u0442 \u0432\u0430\u0448\u0438\u044f \u0444\u0430\u0439\u043b configuration.yaml \u0438 \u0440\u0435\u0441\u0442\u0430\u0440\u0442\u0438\u0440\u0430\u0439\u0442\u0435 Home Assistant, \u0437\u0430 \u0434\u0430 \u043a\u043e\u0440\u0438\u0433\u0438\u0440\u0430\u0442\u0435 \u0442\u043e\u0437\u0438 \u043f\u0440\u043e\u0431\u043b\u0435\u043c.", + "title": "YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 Radarr \u0441\u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u0432\u0430" + }, + "removed_attributes": { + "title": "\u041f\u0440\u043e\u043c\u0435\u043d\u0438 \u0432 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 Radarr" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/radarr/translations/et.json b/homeassistant/components/radarr/translations/et.json new file mode 100644 index 0000000000000000000000000000000000000000..0f91bc2f47b84f2bd70e294d3015832e9715dbd3 --- /dev/null +++ b/homeassistant/components/radarr/translations/et.json @@ -0,0 +1,48 @@ +{ + "config": { + "abort": { + "already_configured": "Teenus on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge", + "wrong_app": "Vale rakendus. Palun proovi uuesti", + "zeroconf_failed": "API v\u00f5tit ei leitud. Sisesta see k\u00e4sitsi" + }, + "step": { + "reauth_confirm": { + "description": "Radarri integratsioon tuleb k\u00e4sitsi uuesti autentida Radarri API abil.", + "title": "Taastuvasta sidumine" + }, + "user": { + "data": { + "api_key": "API v\u00f5ti", + "url": "URL", + "verify_ssl": "Kontrolli SSL serte" + }, + "description": "API-v\u00f5tme saab automaatselt alla laadida, kui rakenduses pole sisselogimismandaate m\u00e4\u00e4ratud.\n API-v\u00f5tme leiate Radarri veebikasutajaliidese jaotisest Seaded > \u00dcldine." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Radarri seadistamine YAML-i abil eemaldatakse.\n\nTeie olemasolev YAML-i konfiguratsioon imporditakse kasutajaliidesesse automaatselt.\n\nEemaldage failist configuration.yaml radarr YAML konfiguratsioon ja taask\u00e4ivitage selle probleemi lahendamiseks Home Assistant.", + "title": "Radarr YAML-i konfiguratsiooni eemaldatakse" + }, + "removed_attributes": { + "description": "M\u00f5ned murrangulised muudatused on tehtud liikumiste arvuanduri v\u00e4ljal\u00fclitamisel ettevaatusabin\u00f5ude t\u00f5ttu.\n\nSee andur v\u00f5ib p\u00f5hjustada probleeme massiivsete andmebaaside puhul. Kui soovite seda siiski kasutada, v\u00f5ite seda teha.\n\nFilmide nimed ei ole enam filmide anduri atribuutidena lisatud.\n\nTulevikus on eemaldatud. Seda ajakohastatakse, nagu kalendrielemendid peaksid olema. Kettaruum on n\u00fc\u00fcd jagatud erinevateks anduriteks, \u00fcks iga kausta jaoks.\n\nStaatus ja k\u00e4sud on eemaldatud, kuna neil ei tundu olevat t\u00f5elist v\u00e4\u00e4rtust automaatika jaoks.", + "title": "Muudatused Radarri integratsioonis" + } + }, + "options": { + "step": { + "init": { + "data": { + "upcoming_days": "Kuvatavate eelseisvate p\u00e4evade arv" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radarr/translations/id.json b/homeassistant/components/radarr/translations/id.json index f6ad3195c71a75960238632ea906144a753d3634..95f19f7136e2c963df5d9db4fd8c785c4ee8be1c 100644 --- a/homeassistant/components/radarr/translations/id.json +++ b/homeassistant/components/radarr/translations/id.json @@ -28,8 +28,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Proses konfigurasi Radarr lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Radarr dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Radarr dalam proses penghapusan" + "description": "Proses konfigurasi Integrasi Radarr lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Integrasi Radarr dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Radarr dalam proses penghapusan" }, "removed_attributes": { "description": "Beberapa perubahan besar telah dilakukan dalam menonaktifkan sensor hitungan Film dengan alasan kehati-hatian.\n\nSensor ini bisa menyebabkan masalah dengan database yang sangat besar. Jika masih ingin menggunakannya, Anda dapat melakukannya.\n\nNama film tidak lagi disertakan sebagai atribut dalam sensor film.\n\nItem \"Yang akan datang\" telah dihapus. Sensor ini sedang dimodernisasi sebagaimana layaknya item kalender. Ruang disk sekarang dipecah ke dalam sensor yang berbeda, satu untuk setiap folder.\n\nStatus dan perintah telah dihapus karena tampaknya tidak membawa nilai dalam otomasi.", diff --git a/homeassistant/components/radarr/translations/ja.json b/homeassistant/components/radarr/translations/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..65d7d08865b222c5351eb9dd2bcc2fda4459d170 --- /dev/null +++ b/homeassistant/components/radarr/translations/ja.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\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", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "reauth_confirm": { + "title": "\u7d71\u5408\u306e\u518d\u8a8d\u8a3c" + }, + "user": { + "data": { + "api_key": "API\u30ad\u30fc", + "url": "URL", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radarr/translations/nb.json b/homeassistant/components/radarr/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..e7b16917b28185b9be6bd5214aa1d5bfae3b46ac --- /dev/null +++ b/homeassistant/components/radarr/translations/nb.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig autentisering", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radarr/translations/nl.json b/homeassistant/components/radarr/translations/nl.json index 436d0998a9e2520d8add5b6de02375cccac0be54..b4f1fcd71067b5f226d42165a5b79b4ff6186a75 100644 --- a/homeassistant/components/radarr/translations/nl.json +++ b/homeassistant/components/radarr/translations/nl.json @@ -7,7 +7,8 @@ "error": { "cannot_connect": "Kan geen verbinding maken", "invalid_auth": "Ongeldige authenticatie", - "unknown": "Onverwachte fout" + "unknown": "Onverwachte fout", + "zeroconf_failed": "API-sleutel niet gevonden. Voer het handmatig in" }, "step": { "reauth_confirm": { @@ -21,5 +22,13 @@ } } } + }, + "issues": { + "deprecated_yaml": { + "title": "De Radarr YAML-configuratie wordt verwijderd" + }, + "removed_attributes": { + "title": "Wijzigingen in de Radarr-integratie" + } } } \ No newline at end of file diff --git a/homeassistant/components/radarr/translations/no.json b/homeassistant/components/radarr/translations/no.json index 4b6b2adb523261eb5e46df7cc9f9da89ae52e17f..4a86867a3fd06fab582546614b2ceab484bdd5a6 100644 --- a/homeassistant/components/radarr/translations/no.json +++ b/homeassistant/components/radarr/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Tjenesten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/radarr/translations/pl.json b/homeassistant/components/radarr/translations/pl.json new file mode 100644 index 0000000000000000000000000000000000000000..ff7092eaff0a768f1a5ed29fb6d8680c547b5913 --- /dev/null +++ b/homeassistant/components/radarr/translations/pl.json @@ -0,0 +1,48 @@ +{ + "config": { + "abort": { + "already_configured": "Us\u0142uga jest ju\u017c skonfigurowana", + "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", + "wrong_app": "Osi\u0105gni\u0119to nieprawid\u0142ow\u0105 aplikacj\u0119. Spr\u00f3buj ponownie", + "zeroconf_failed": "Nie znaleziono klucza API. Prosz\u0119 wpisa\u0107 go r\u0119cznie." + }, + "step": { + "reauth_confirm": { + "description": "Integracja Radarr musi zosta\u0107 r\u0119cznie ponownie uwierzytelniona za pomoc\u0105 interfejsu API Radarr", + "title": "Ponownie uwierzytelnij integracj\u0119" + }, + "user": { + "data": { + "api_key": "Klucz API", + "url": "URL", + "verify_ssl": "Weryfikacja certyfikatu SSL" + }, + "description": "Klucz API mo\u017ce zosta\u0107 pobrany automatycznie, je\u015bli dane logowania nie zosta\u0142y ustawione w aplikacji.\nTw\u00f3j klucz API mo\u017cesz znale\u017a\u0107 w Ustawienia > Og\u00f3lne, na swoim koncie Radarr." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Konfiguracja Radarr przy u\u017cyciu YAML zostanie usuni\u0119ta. \n\nTwoja istniej\u0105ca konfiguracja YAML zosta\u0142a automatycznie zaimportowana do interfejsu u\u017cytkownika. \n\nUsu\u0144 konfiguracj\u0119 YAML z pliku configuration.yaml i uruchom ponownie Home Assistanta, aby rozwi\u0105za\u0107 ten problem.", + "title": "Konfiguracja YAML dla Radarr zostanie usuni\u0119ta" + }, + "removed_attributes": { + "description": "Z powodu ostro\u017cno\u015bci wprowadzono pewne prze\u0142omowe zmiany w wy\u0142\u0105czaniu sensora liczby film\u00f3w. \n\nTen sensor mo\u017ce powodowa\u0107 problemy z ogromnymi bazami danych. Je\u015bli nadal chcesz z niego korzysta\u0107, mo\u017cesz to zrobi\u0107. \n\n\"Nazwy film\u00f3w\" nie s\u0105 ju\u017c uwzgl\u0119dniane w atrybutach sensora film\u00f3w. \n\n\"Nadchodz\u0105ce\" zosta\u0142o usuni\u0119te. Jest unowocze\u015bniany tak, jak przysta\u0142o na kalendarz. \"Miejsce na dysku\" jest teraz podzielone na r\u00f3\u017cne sensory, po jednym dla ka\u017cdego folderu. \n\n\"Status\" i \"Polecenia\" zosta\u0142y usuni\u0119te, poniewa\u017c nie wydaj\u0105 si\u0119 mie\u0107 rzeczywistej warto\u015bci dla automatyzacji.", + "title": "Zmiany w integracji Radarr" + } + }, + "options": { + "step": { + "init": { + "data": { + "upcoming_days": "Liczba nadchodz\u0105cych dni do wy\u015bwietlenia" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radarr/translations/pt-BR.json b/homeassistant/components/radarr/translations/pt-BR.json index 74d33fa613663ebcb3e61d3b78f666b0f749fdb8..78b9078cb9f39ede417e90c814ddbfd593010fbb 100644 --- a/homeassistant/components/radarr/translations/pt-BR.json +++ b/homeassistant/components/radarr/translations/pt-BR.json @@ -5,7 +5,7 @@ "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", "unknown": "Erro inesperado", "wrong_app": "Aplica\u00e7\u00e3o incorreta alcan\u00e7ada. Por favor, tente novamente", @@ -18,9 +18,9 @@ }, "user": { "data": { - "api_key": "Chave de API", + "api_key": "Chave da API", "url": "URL", - "verify_ssl": "Verificar certificado SSL" + "verify_ssl": "Verifique o certificado SSL" }, "description": "A chave de API pode ser recuperada automaticamente se as credenciais de login n\u00e3o tiverem sido definidas no aplicativo.\n Sua chave de API pode ser encontrada em Configura\u00e7\u00f5es > Geral na interface da Web do Radarr." } diff --git a/homeassistant/components/radarr/translations/sv.json b/homeassistant/components/radarr/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..a5f84833bd9918702af2baa75bb79fbc36becbd5 --- /dev/null +++ b/homeassistant/components/radarr/translations/sv.json @@ -0,0 +1,48 @@ +{ + "config": { + "abort": { + "already_configured": "Tj\u00e4nsten \u00e4r redan konfigurerad", + "reauth_successful": "\u00c5terautentisering lyckades" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel", + "wrong_app": "Felaktig applikation har n\u00e5tts. F\u00f6rs\u00f6k igen", + "zeroconf_failed": "API-nyckeln har inte hittats. Ange den manuellt" + }, + "step": { + "reauth_confirm": { + "description": "Radarr-integrationen m\u00e5ste autentiseras manuellt med Radarr API", + "title": "\u00c5terautenticera integration" + }, + "user": { + "data": { + "api_key": "API-nyckel", + "url": "URL", + "verify_ssl": "Verifiera SSL-certifikat" + }, + "description": "API-nyckel kan h\u00e4mtas automatiskt om inloggningsuppgifter inte st\u00e4llts in i applikationen.\n Din API-nyckel finns i Inst\u00e4llningar > Allm\u00e4nt i Radarr Web UI." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Konfigurering av Radarr med YAML tas bort. \n\n Din befintliga YAML-konfiguration har automatiskt importerats till anv\u00e4ndargr\u00e4nssnittet. \n\n Ta bort Radarr YAML-konfigurationen fr\u00e5n filen configuration.yaml och starta om Home Assistant f\u00f6r att \u00e5tg\u00e4rda problemet.", + "title": "Radarr YAML-konfigurationen tas bort" + }, + "removed_attributes": { + "description": "Vissa f\u00f6r\u00e4ndringar har gjorts f\u00f6r att inaktivera r\u00e4knesensorn f\u00f6r filmer av f\u00f6rsiktighet. \n\n Denna sensor kan orsaka problem med massiva databaser. Om du fortfarande vill anv\u00e4nda den kan du g\u00f6ra det. \n\n Filmnamn ing\u00e5r inte l\u00e4ngre som attribut i filmsensorn. \n\n Kommande har tagits bort. Det h\u00e5ller p\u00e5 att moderniseras som kalenderobjekt ska vara. Diskutrymme \u00e4r nu uppdelat i olika sensorer, en f\u00f6r varje mapp. \n\n Status och kommandon har tagits bort eftersom de inte verkar ha n\u00e5got verkligt v\u00e4rde f\u00f6r automatiseringar.", + "title": "\u00c4ndringar av Radarr-integrationen" + } + }, + "options": { + "step": { + "init": { + "data": { + "upcoming_days": "Antal kommande dagar att visa" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radarr/translations/tr.json b/homeassistant/components/radarr/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..256cba8564704b5657d614d8680c96f917bba6d5 --- /dev/null +++ b/homeassistant/components/radarr/translations/tr.json @@ -0,0 +1,48 @@ +{ + "config": { + "abort": { + "already_configured": "Hizmet 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", + "wrong_app": "Yanl\u0131\u015f uygulamaya ula\u015f\u0131ld\u0131. L\u00fctfen tekrar deneyin", + "zeroconf_failed": "API anahtar\u0131 bulunamad\u0131. L\u00fctfen manuel olarak girin" + }, + "step": { + "reauth_confirm": { + "description": "Radarr entegrasyonunun Radarr API ile manuel olarak yeniden do\u011frulanmas\u0131 gerekiyor", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, + "user": { + "data": { + "api_key": "API Anahtar\u0131", + "url": "URL", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "description": "Giri\u015f kimlik bilgileri uygulamada ayarlanmad\u0131ysa API anahtar\u0131 otomatik olarak al\u0131nabilir.\n API anahtar\u0131n\u0131z, Radarr Web Kullan\u0131c\u0131 Aray\u00fcz\u00fcndeki Ayarlar > Genel b\u00f6l\u00fcm\u00fcnde bulunabilir." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Radarr'\u0131n YAML kullan\u0131larak yap\u0131land\u0131r\u0131lmas\u0131 kald\u0131r\u0131l\u0131yor. \n\n Mevcut YAML yap\u0131land\u0131rman\u0131z otomatik olarak kullan\u0131c\u0131 aray\u00fcz\u00fcne aktar\u0131ld\u0131. \n\n Radarr YAML yap\u0131land\u0131rmas\u0131n\u0131 configuration.yaml dosyan\u0131zdan kald\u0131r\u0131n ve bu sorunu gidermek i\u00e7in Home Assistant'\u0131 yeniden ba\u015flat\u0131n.", + "title": "Radarr YAML yap\u0131land\u0131rmas\u0131 kald\u0131r\u0131l\u0131yor" + }, + "removed_attributes": { + "description": "Film sayma sens\u00f6r\u00fcn\u00fcn dikkatli bir \u015fekilde devre d\u0131\u015f\u0131 b\u0131rak\u0131lmas\u0131nda baz\u0131 \u00f6nemli de\u011fi\u015fiklikler yap\u0131ld\u0131. \n\n Bu sens\u00f6r, b\u00fcy\u00fck veritabanlar\u0131nda sorunlara neden olabilir. Hala kullanmak istiyorsan\u0131z, bunu yapabilirsiniz. \n\n Film adlar\u0131 art\u0131k film sens\u00f6r\u00fcne \u00f6znitelik olarak dahil edilmemektedir. \n\n Yakla\u015fan kald\u0131r\u0131ld\u0131. Takvim \u00f6\u011feleri olmas\u0131 gerekti\u011fi gibi modernize ediliyor. Disk alan\u0131 art\u0131k her klas\u00f6r i\u00e7in bir tane olmak \u00fczere farkl\u0131 sens\u00f6rlere b\u00f6l\u00fcnm\u00fc\u015ft\u00fcr. \n\n Otomasyonlar i\u00e7in ger\u00e7ek de\u011feri olmad\u0131\u011f\u0131 i\u00e7in durum ve komutlar kald\u0131r\u0131ld\u0131.", + "title": "Radarr entegrasyonundaki de\u011fi\u015fiklikler" + } + }, + "options": { + "step": { + "init": { + "data": { + "upcoming_days": "G\u00f6r\u00fcnt\u00fclenecek yakla\u015fan g\u00fcn say\u0131s\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radarr/translations/zh-Hans.json b/homeassistant/components/radarr/translations/zh-Hans.json new file mode 100644 index 0000000000000000000000000000000000000000..fe3b28ff5b6597f153366d25106e43bfac5b02ae --- /dev/null +++ b/homeassistant/components/radarr/translations/zh-Hans.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u91cd\u65b0\u8ba4\u8bc1\u6210\u529f" + }, + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25", + "invalid_auth": "\u9a8c\u8bc1\u5931\u8d25", + "unknown": "\u672a\u77e5\u9519\u8bef" + }, + "step": { + "reauth_confirm": { + "title": "\u91cd\u65b0\u9a8c\u8bc1\u96c6\u6210" + }, + "user": { + "data": { + "api_key": "API\u79d8\u94a5", + "url": "URL", + "verify_ssl": "\u9a8c\u8bc1SSL\u8bc1\u4e66" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radio_browser/manifest.json b/homeassistant/components/radio_browser/manifest.json index 9c6858ae27eb9d754ba073aa7583499e013f864c..83a87bb800f0311f66bd8b04f534f53d810134b3 100644 --- a/homeassistant/components/radio_browser/manifest.json +++ b/homeassistant/components/radio_browser/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/radio", "requirements": ["radios==0.1.1"], "codeowners": ["@frenck"], + "integration_type": "service", "iot_class": "cloud_polling" } diff --git a/homeassistant/components/radiotherm/translations/id.json b/homeassistant/components/radiotherm/translations/id.json index 21198e3fad10de13c8960524ba068a76601534f0..41cc6d3736eedb7b8703ca24aceb0443731458d5 100644 --- a/homeassistant/components/radiotherm/translations/id.json +++ b/homeassistant/components/radiotherm/translations/id.json @@ -22,7 +22,7 @@ "issues": { "deprecated_yaml": { "description": "Proses konfigurasi platform cuaca Radio Thermostat lewat YAML dalam proses penghapusan di Home Assistant 2022.9.\n\nKonfigurasi yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML platform cuaca Radio Thermostat dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Radio Thermostat dalam proses penghapusan" + "title": "Konfigurasi YAML Integrasi Radio Thermostat dalam proses penghapusan" } }, "options": { diff --git a/homeassistant/components/radiotherm/translations/nb.json b/homeassistant/components/radiotherm/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/radiotherm/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py index 33fd3cf15203606af24116b28dba9ce2a111865d..40a9514e1d7fe797b405026e0fa9cfe1209f6e9b 100644 --- a/homeassistant/components/raincloud/__init__.py +++ b/homeassistant/components/raincloud/__init__.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, @@ -27,8 +26,6 @@ _LOGGER = logging.getLogger(__name__) ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] -ATTRIBUTION = "Data provided by Melnor Aquatimer.com" - CONF_WATERING_TIME = "watering_minutes" NOTIFICATION_ID = "raincloud_notification" @@ -140,6 +137,8 @@ class RainCloudHub: class RainCloudEntity(Entity): """Entity class for RainCloud devices.""" + _attr_attribution = "Data provided by Melnor Aquatimer.com" + def __init__(self, data, sensor_type): """Initialize the RainCloud entity.""" self.data = data @@ -167,7 +166,7 @@ class RainCloudEntity(Entity): @property def extra_state_attributes(self): """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION, "identifier": self.data.serial} + return {"identifier": self.data.serial} @property def icon(self): diff --git a/homeassistant/components/raincloud/switch.py b/homeassistant/components/raincloud/switch.py index 89b2673f66eb4f31d666c2327c831f93e142104d..abcb680daa34b60f5505b112586a727865abfa12 100644 --- a/homeassistant/components/raincloud/switch.py +++ b/homeassistant/components/raincloud/switch.py @@ -7,7 +7,7 @@ from typing import Any import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -15,7 +15,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ( ALLOWED_WATERING_TIME, - ATTRIBUTION, CONF_WATERING_TIME, DATA_RAINCLOUD, DEFAULT_WATERING_TIME, @@ -97,7 +96,6 @@ class RainCloudSwitch(RainCloudEntity, SwitchEntity): def extra_state_attributes(self): """Return the state attributes.""" return { - ATTR_ATTRIBUTION: ATTRIBUTION, "default_manual_timer": self._default_watering_timer, "identifier": self.data.serial, } diff --git a/homeassistant/components/rainforest_eagle/translations/nb.json b/homeassistant/components/rainforest_eagle/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/rainforest_eagle/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 8cc3b3d5e807fed63faba61292c74ae5b5fa62ef..a0b11653272cb8d948a31aec7c378882d5d5ee73 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -20,6 +20,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_UNIT_OF_MEASUREMENT, Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -79,9 +80,19 @@ CONF_SECONDS = "seconds" CONF_SOLARRAD = "solarrad" CONF_TEMPERATURE = "temperature" CONF_TIMESTAMP = "timestamp" +CONF_UNITS = "units" +CONF_VALUE = "value" CONF_WEATHER = "weather" CONF_WIND = "wind" +# Config Validator for Flow Meter Data +CV_FLOW_METER_VALID_UNITS = { + "clicks", + "gal", + "litre", + "m3", +} + # Config Validators for Weather Service Data CV_WX_DATA_VALID_PERCENTAGE = vol.All(vol.Coerce(int), vol.Range(min=0, max=100)) CV_WX_DATA_VALID_TEMP_RANGE = vol.All(vol.Coerce(float), vol.Range(min=-40.0, max=40.0)) @@ -91,6 +102,7 @@ CV_WX_DATA_VALID_PRESSURE = vol.All(vol.Coerce(float), vol.Range(min=60.0, max=1 CV_WX_DATA_VALID_SOLARRAD = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=5.0)) SERVICE_NAME_PAUSE_WATERING = "pause_watering" +SERVICE_NAME_PUSH_FLOW_METER_DATA = "push_flow_meter_data" SERVICE_NAME_PUSH_WEATHER_DATA = "push_weather_data" SERVICE_NAME_RESTRICT_WATERING = "restrict_watering" SERVICE_NAME_STOP_ALL = "stop_all" @@ -109,6 +121,15 @@ SERVICE_PAUSE_WATERING_SCHEMA = SERVICE_SCHEMA.extend( } ) +SERVICE_PUSH_FLOW_METER_DATA_SCHEMA = SERVICE_SCHEMA.extend( + { + vol.Required(CONF_VALUE): cv.positive_float, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.All( + cv.string, vol.In(CV_FLOW_METER_VALID_UNITS) + ), + } +) + SERVICE_PUSH_WEATHER_DATA_SCHEMA = SERVICE_SCHEMA.extend( { vol.Optional(CONF_TIMESTAMP): cv.positive_float, @@ -320,6 +341,17 @@ async def async_setup_entry( # noqa: C901 """Pause watering for a set number of seconds.""" await controller.watering.pause_all(call.data[CONF_SECONDS]) + @call_with_controller(update_programs_and_zones=False) + async def async_push_flow_meter_data( + call: ServiceCall, controller: Controller + ) -> None: + """Push flow meter data to the device.""" + value = call.data[CONF_VALUE] + if units := call.data.get(CONF_UNIT_OF_MEASUREMENT): + await controller.watering.post_flowmeter(value=value, units=units) + else: + await controller.watering.post_flowmeter(value=value) + @call_with_controller(update_programs_and_zones=False) async def async_push_weather_data( call: ServiceCall, controller: Controller @@ -378,6 +410,11 @@ async def async_setup_entry( # noqa: C901 SERVICE_PAUSE_WATERING_SCHEMA, async_pause_watering, ), + ( + SERVICE_NAME_PUSH_FLOW_METER_DATA, + SERVICE_PUSH_FLOW_METER_DATA_SCHEMA, + async_push_flow_meter_data, + ), ( SERVICE_NAME_PUSH_WEATHER_DATA, SERVICE_PUSH_WEATHER_DATA_SCHEMA, @@ -419,6 +456,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # defined during integration setup: for service_name in ( SERVICE_NAME_PAUSE_WATERING, + SERVICE_NAME_PUSH_FLOW_METER_DATA, SERVICE_NAME_PUSH_WEATHER_DATA, SERVICE_NAME_RESTRICT_WATERING, SERVICE_NAME_STOP_ALL, diff --git a/homeassistant/components/rainmachine/diagnostics.py b/homeassistant/components/rainmachine/diagnostics.py index 47ded7990c65ccc10fbe9037849dc32126fed3c6..e4835d514e6b9c4b91ed780d1718d24dc04ca1ec 100644 --- a/homeassistant/components/rainmachine/diagnostics.py +++ b/homeassistant/components/rainmachine/diagnostics.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_PASSWORD, + CONF_UNIQUE_ID, ) from homeassistant.core import HomeAssistant @@ -32,6 +33,8 @@ TO_REDACT = { CONF_STATION_NAME, CONF_STATION_SOURCE, CONF_TIMEZONE, + # Config entry unique ID may contain sensitive data: + CONF_UNIQUE_ID, } @@ -47,20 +50,16 @@ async def async_get_config_entry_diagnostics( LOGGER.warning("Unable to download controller-specific diagnostics") controller_diagnostics = None - return { - "entry": { - "title": entry.title, - "data": async_redact_data(entry.data, TO_REDACT), - "options": dict(entry.options), - }, - "data": { - "coordinator": async_redact_data( - { + return async_redact_data( + { + "entry": entry.as_dict(), + "data": { + "coordinator": { api_category: controller.data for api_category, controller in data.coordinators.items() }, - TO_REDACT, - ), - "controller_diagnostics": controller_diagnostics, + "controller_diagnostics": controller_diagnostics, + }, }, - } + TO_REDACT, + ) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index ca7543bfb38feb4a89f5f2b0ce11049e8d2d5720..a41db1d18f95e8e0583bdf346310395f1237560a 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -3,7 +3,7 @@ "name": "RainMachine", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rainmachine", - "requirements": ["regenmaschine==2022.09.2"], + "requirements": ["regenmaschine==2022.10.0"], "codeowners": ["@bachya"], "iot_class": "local_polling", "homekit": { @@ -15,5 +15,6 @@ "name": "rainmachine*" } ], - "loggers": ["regenmaschine"] + "loggers": ["regenmaschine"], + "integration_type": "device" } diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py index 0f3b8d10be213bf2ac553d1f3396521e57bee363..9010b9b4fed003509131211ef9ef2c8d3b22ad27 100644 --- a/homeassistant/components/rainmachine/select.py +++ b/homeassistant/components/rainmachine/select.py @@ -7,11 +7,11 @@ from regenmaschine.errors import RainMachineError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem from . import RainMachineData, RainMachineEntity from .const import DATA_RESTRICTIONS_UNIVERSAL, DOMAIN @@ -44,7 +44,7 @@ class FreezeProtectionSelectOption: class FreezeProtectionTemperatureMixin: """Define an entity description mixin to include an options list.""" - options: list[FreezeProtectionSelectOption] + extended_options: list[FreezeProtectionSelectOption] @dataclass @@ -64,7 +64,7 @@ SELECT_DESCRIPTIONS = ( entity_category=EntityCategory.CONFIG, api_category=DATA_RESTRICTIONS_UNIVERSAL, data_key="freezeProtectTemp", - options=[ + extended_options=[ FreezeProtectionSelectOption( api_value=0.0, imperial_label="32°F", @@ -101,7 +101,7 @@ async def async_setup_entry( } async_add_entities( - entity_map[description.key](entry, data, description, hass.config.units.name) + entity_map[description.key](entry, data, description, hass.config.units) for description in SELECT_DESCRIPTIONS if ( (coordinator := data.coordinators[description.api_category]) is not None @@ -121,7 +121,7 @@ class FreezeProtectionTemperatureSelect(RainMachineEntity, SelectEntity): entry: ConfigEntry, data: RainMachineData, description: FreezeProtectionSelectDescription, - unit_system: str, + unit_system: UnitSystem, ) -> None: """Initialize.""" super().__init__(entry, data, description) @@ -129,8 +129,8 @@ class FreezeProtectionTemperatureSelect(RainMachineEntity, SelectEntity): self._api_value_to_label_map = {} self._label_to_api_value_map = {} - for option in description.options: - if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + for option in description.extended_options: + if unit_system is US_CUSTOMARY_SYSTEM: label = option.imperial_label else: label = option.metric_label diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 97772c6033ae0b6411ae6ea07146a6d47a91df4d..191ace646250acc9ab95046d5ade04d939a6e69c 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -14,11 +14,11 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS, VOLUME_CUBIC_METERS +from homeassistant.const import TEMP_CELSIUS, VOLUME_CUBIC_METERS, VOLUME_LITERS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.dt import utcnow +from homeassistant.util.dt import utc_from_timestamp, utcnow from . import RainMachineData, RainMachineEntity from .const import ( @@ -45,10 +45,14 @@ DEFAULT_ZONE_COMPLETION_TIME_WOBBLE_TOLERANCE = timedelta(seconds=5) TYPE_FLOW_SENSOR_CLICK_M3 = "flow_sensor_clicks_cubic_meter" TYPE_FLOW_SENSOR_CONSUMED_LITERS = "flow_sensor_consumed_liters" +TYPE_FLOW_SENSOR_LEAK_CLICKS = "flow_sensor_leak_clicks" +TYPE_FLOW_SENSOR_LEAK_VOLUME = "flow_sensor_leak_volume" 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_LAST_LEAK_DETECTED = "last_leak_detected" TYPE_PROGRAM_RUN_COMPLETION_TIME = "program_run_completion_time" +TYPE_RAIN_SENSOR_RAIN_START = "rain_sensor_rain_start" TYPE_ZONE_RUN_COMPLETION_TIME = "zone_run_completion_time" @@ -67,7 +71,7 @@ class RainMachineSensorCompletionTimerDescription( RainMachineEntityDescription, RainMachineEntityDescriptionMixinUid, ): - """Describe a RainMachine sensor.""" + """Describe a RainMachine completion timer sensor.""" SENSOR_DESCRIPTIONS = ( @@ -87,12 +91,34 @@ SENSOR_DESCRIPTIONS = ( name="Flow sensor consumed liters", icon="mdi:water-pump", entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement="liter", + native_unit_of_measurement=VOLUME_LITERS, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, api_category=DATA_PROVISION_SETTINGS, data_key="flowSensorWateringClicks", ), + RainMachineSensorDataDescription( + key=TYPE_FLOW_SENSOR_LEAK_CLICKS, + name="Flow sensor leak clicks", + icon="mdi:pipe-leak", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="clicks", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + api_category=DATA_PROVISION_SETTINGS, + data_key="flowSensorLeakClicks", + ), + RainMachineSensorDataDescription( + key=TYPE_FLOW_SENSOR_LEAK_VOLUME, + name="Flow sensor leak volume", + icon="mdi:pipe-leak", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=VOLUME_LITERS, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + api_category=DATA_PROVISION_SETTINGS, + data_key="flowSensorLeakClicks", + ), RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_START_INDEX, name="Flow sensor start index", @@ -110,7 +136,7 @@ SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement="clicks", entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, api_category=DATA_PROVISION_SETTINGS, data_key="flowSensorWateringClicks", ), @@ -125,6 +151,28 @@ SENSOR_DESCRIPTIONS = ( api_category=DATA_RESTRICTIONS_UNIVERSAL, data_key="freezeProtectTemp", ), + RainMachineSensorDataDescription( + key=TYPE_LAST_LEAK_DETECTED, + name="Last leak detected", + icon="mdi:pipe-leak", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + state_class=SensorStateClass.MEASUREMENT, + api_category=DATA_PROVISION_SETTINGS, + data_key="lastLeakDetected", + ), + RainMachineSensorDataDescription( + key=TYPE_RAIN_SENSOR_RAIN_START, + name="Rain sensor rain start", + icon="mdi:weather-pouring", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + state_class=SensorStateClass.MEASUREMENT, + api_category=DATA_PROVISION_SETTINGS, + data_key="rainSensorRainStart", + ), ) @@ -293,30 +341,36 @@ class ProvisionSettingsSensor(RainMachineEntity, SensorEntity): @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self.entity_description.key == TYPE_FLOW_SENSOR_CLICK_M3: - self._attr_native_value = self.coordinator.data.get("system", {}).get( - "flowSensorClicksPerCubicMeter" - ) - elif self.entity_description.key == TYPE_FLOW_SENSOR_CONSUMED_LITERS: - clicks = self.coordinator.data.get("system", {}).get( - "flowSensorWateringClicks" - ) - clicks_per_m3 = self.coordinator.data.get("system", {}).get( - "flowSensorClicksPerCubicMeter" - ) + system = self.coordinator.data.get("system", {}) + new_value = system.get(self.entity_description.data_key) - if clicks and clicks_per_m3: - self._attr_native_value = (clicks * 1000) / clicks_per_m3 + # Calculate volumetric sensors + if ( + self.entity_description.key + in { + TYPE_FLOW_SENSOR_CONSUMED_LITERS, + TYPE_FLOW_SENSOR_LEAK_VOLUME, + } + and new_value + ): + if clicks_per_m3 := system.get("flowSensorClicksPerCubicMeter"): + self._attr_native_value = round((new_value * 1000) / clicks_per_m3, 1) + return + + # Convert timestamp sensors to datetime + if self.entity_description.key in { + TYPE_LAST_LEAK_DETECTED, + TYPE_RAIN_SENSOR_RAIN_START, + }: + # Timestamp may return 0 instead of null, explicitly set to None + if new_value: + self._attr_native_value = utc_from_timestamp(new_value) else: self._attr_native_value = None - elif self.entity_description.key == TYPE_FLOW_SENSOR_START_INDEX: - self._attr_native_value = self.coordinator.data.get("system", {}).get( - "flowSensorStartIndex" - ) - elif self.entity_description.key == TYPE_FLOW_SENSOR_WATERING_CLICKS: - self._attr_native_value = self.coordinator.data.get("system", {}).get( - "flowSensorWateringClicks" - ) + return + + # Return all other sensor values or None + self._attr_native_value = new_value class UniversalRestrictionsSensor(RainMachineEntity, SensorEntity): diff --git a/homeassistant/components/rainmachine/services.yaml b/homeassistant/components/rainmachine/services.yaml index 198aec94a2298efd9b96938104c842c506726348..9aa2bb7f50a4757476592cf89d0288e8bf36f226 100644 --- a/homeassistant/components/rainmachine/services.yaml +++ b/homeassistant/components/rainmachine/services.yaml @@ -97,6 +97,37 @@ unpause_watering: selector: device: integration: rainmachine +push_flow_meter_data: + name: Push Flow Meter Data + description: Push Flow Meter data to the RainMachine device. + fields: + device_id: + name: Controller + description: The controller to send flow meter data to + required: true + selector: + device: + integration: rainmachine + value: + name: Value + description: The flow meter value to send + required: true + selector: + number: + min: 0.0 + max: 1000000000.0 + step: 0.1 + mode: box + unit_of_measurement: + name: Unit of Measurement + description: The flow meter units to send + selector: + select: + options: + - "clicks" + - "gal" + - "litre" + - "m3" push_weather_data: name: Push Weather Data description: >- diff --git a/homeassistant/components/rainmachine/translations/en.json b/homeassistant/components/rainmachine/translations/en.json index 9369eeae4c84d6ce1e9fc11e49f10b2536a30453..3e5d824ee084c49704bf96a485c9943898c1de7d 100644 --- a/homeassistant/components/rainmachine/translations/en.json +++ b/homeassistant/components/rainmachine/translations/en.json @@ -18,6 +18,19 @@ } } }, + "issues": { + "replaced_old_entity": { + "fix_flow": { + "step": { + "confirm": { + "description": "Update any automations or scripts that use this entity to instead use `{replacement_entity_id}`.", + "title": "The {old_entity_id} entity will be removed" + } + } + }, + "title": "The {old_entity_id} entity will be removed" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/rainmachine/translations/et.json b/homeassistant/components/rainmachine/translations/et.json index cad1284ad3d4a08ffc7c487769bbaf3b0a84dca7..0a9d6e007f13921653b40c3efe95a42e3a5e0fba 100644 --- a/homeassistant/components/rainmachine/translations/et.json +++ b/homeassistant/components/rainmachine/translations/et.json @@ -18,6 +18,19 @@ } } }, + "issues": { + "replaced_old_entity": { + "fix_flow": { + "step": { + "confirm": { + "description": "V\u00e4rskendage automaatikaid v\u00f5i skripte, mis seda olemit kasutavad, et kasutada selle asemel '{replacement_entity_id}'.", + "title": "\u00dcksus {old_entity_id} eemaldatakse" + } + } + }, + "title": "\u00dcksus {old_entity_id} eemaldatakse" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/rainmachine/translations/nl.json b/homeassistant/components/rainmachine/translations/nl.json index cbf76b879cba2aeec877cca12d2eead45d61f849..4fa3285c03bd4fe14182b907e79d1dd5face471d 100644 --- a/homeassistant/components/rainmachine/translations/nl.json +++ b/homeassistant/components/rainmachine/translations/nl.json @@ -18,6 +18,18 @@ } } }, + "issues": { + "replaced_old_entity": { + "fix_flow": { + "step": { + "confirm": { + "title": "De {old_entity_id}-entiteit wordt verwijderd" + } + } + }, + "title": "De {old_entity_id}-entiteit wordt verwijderd" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/rainmachine/translations/pl.json b/homeassistant/components/rainmachine/translations/pl.json index 665152e8e0f87b5699dc12033f8a7fbdfced0b8d..e96ccd64ef059088c8cf78593d0ebc847217dedb 100644 --- a/homeassistant/components/rainmachine/translations/pl.json +++ b/homeassistant/components/rainmachine/translations/pl.json @@ -18,6 +18,19 @@ } } }, + "issues": { + "replaced_old_entity": { + "fix_flow": { + "step": { + "confirm": { + "description": "Zaktualizuj wszystkie automatyzacje lub skrypty, kt\u00f3re u\u017cywaj\u0105 tej encji, aby zamiast tego u\u017cywa\u0142y `{replacement_entity_id}`.", + "title": "Encja {old_entity_id} zostanie usuni\u0119ta" + } + } + }, + "title": "Encja {old_entity_id} zostanie usuni\u0119ta" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/rainmachine/translations/sv.json b/homeassistant/components/rainmachine/translations/sv.json index 10e0669320750963c2e254d9b6a34525fec59f27..9cf860dee345e8d68cad876eba79ad9470dbef5d 100644 --- a/homeassistant/components/rainmachine/translations/sv.json +++ b/homeassistant/components/rainmachine/translations/sv.json @@ -18,6 +18,19 @@ } } }, + "issues": { + "replaced_old_entity": { + "fix_flow": { + "step": { + "confirm": { + "description": "Uppdatera alla automatiseringar eller skript som anv\u00e4nder denna enhet f\u00f6r att ist\u00e4llet anv\u00e4nda ` {replacement_entity_id} `.", + "title": "{old_entity_id} kommer att tas bort" + } + } + }, + "title": "{old_entity_id} kommer att tas bort" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/rainmachine/translations/tr.json b/homeassistant/components/rainmachine/translations/tr.json index 7db1bffd5c8c63e63c6ecbd82f1532414145021c..fa181de3505c953bc9b159cd786b4099c7c77a79 100644 --- a/homeassistant/components/rainmachine/translations/tr.json +++ b/homeassistant/components/rainmachine/translations/tr.json @@ -18,6 +18,19 @@ } } }, + "issues": { + "replaced_old_entity": { + "fix_flow": { + "step": { + "confirm": { + "description": "Bunun yerine ` {replacement_entity_id} ` kullanmak i\u00e7in bu varl\u0131\u011f\u0131 kullanan t\u00fcm otomasyonlar\u0131 veya komut dosyalar\u0131n\u0131 g\u00fcncelleyin.", + "title": "{old_entity_id} varl\u0131\u011f\u0131 kald\u0131r\u0131lacak" + } + } + }, + "title": "{old_entity_id} varl\u0131\u011f\u0131 kald\u0131r\u0131lacak" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/raven_rock_mfg/manifest.json b/homeassistant/components/raven_rock_mfg/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..7508509488826c074740d662bedb2bb5bdd61179 --- /dev/null +++ b/homeassistant/components/raven_rock_mfg/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "raven_rock_mfg", + "name": "Raven Rock MFG", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 774d0234d061fe3e28226568d73c12467c3521b3..da0f076b3d8e81ca1152059e18723f0272fdef3f 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -6,5 +6,6 @@ "requirements": ["vehicle==0.4.0"], "codeowners": ["@frenck"], "quality_scale": "platinum", + "integration_type": "service", "iot_class": "cloud_polling" } diff --git a/homeassistant/components/recollect_waste/diagnostics.py b/homeassistant/components/recollect_waste/diagnostics.py index fb19b1790f584936a9659ba6b7c67672a07239fd..d410eb4008508c0c873cc7b37cfd20c004571967 100644 --- a/homeassistant/components/recollect_waste/diagnostics.py +++ b/homeassistant/components/recollect_waste/diagnostics.py @@ -4,11 +4,24 @@ from __future__ import annotations import dataclasses from typing import Any +from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import CONF_PLACE_ID, DOMAIN + +CONF_AREA_NAME = "area_name" +CONF_TITLE = "title" + +TO_REDACT = { + CONF_AREA_NAME, + CONF_PLACE_ID, + # Config entry title and unique ID may contain sensitive data: + CONF_TITLE, + CONF_UNIQUE_ID, +} async def async_get_config_entry_diagnostics( @@ -17,7 +30,10 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return { - "entry": entry.as_dict(), - "data": [dataclasses.asdict(event) for event in coordinator.data], - } + return async_redact_data( + { + "entry": entry.as_dict(), + "data": [dataclasses.asdict(event) for event in coordinator.data], + }, + TO_REDACT, + ) diff --git a/homeassistant/components/recollect_waste/manifest.json b/homeassistant/components/recollect_waste/manifest.json index 68fc0e2c30926464f65cd855009e410687919566..e80f8e35088f57043eba28c50f18476ff7c71371 100644 --- a/homeassistant/components/recollect_waste/manifest.json +++ b/homeassistant/components/recollect_waste/manifest.json @@ -6,5 +6,6 @@ "requirements": ["aiorecollect==1.0.8"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", - "loggers": ["aiorecollect"] + "loggers": ["aiorecollect"], + "integration_type": "service" } diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index f9ed5f59333710d991977d0a8d5c4a5499da3a4b..0d4bfe8e59bc8cd4547fd30b29de691a4080af5d 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -69,7 +69,10 @@ ALLOW_IN_MEMORY_DB = False def validate_db_url(db_url: str) -> Any: """Validate database URL.""" # Don't allow on-memory sqlite databases - if (db_url == SQLITE_URL_PREFIX or ":memory:" in db_url) and not ALLOW_IN_MEMORY_DB: + if ( + db_url == SQLITE_URL_PREFIX + or (db_url.startswith(SQLITE_URL_PREFIX) and ":memory:" in db_url) + ) and not ALLOW_IN_MEMORY_DB: raise vol.Invalid("In-memory SQLite database is not supported") return db_url diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 532644c7feb2e8e6f3a7e800db51fc413d0c7a67..66a9818b4b8a7f1678e2cf30b2f930875916716b 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -8,7 +8,10 @@ from homeassistant.helpers.json import ( # noqa: F401 pylint: disable=unused-im DATA_INSTANCE = "recorder_instance" SQLITE_URL_PREFIX = "sqlite://" +MARIADB_URL_PREFIX = "mariadb://" +MARIADB_PYMYSQL_URL_PREFIX = "mariadb+pymysql://" MYSQLDB_URL_PREFIX = "mysql://" +MYSQLDB_PYMYSQL_URL_PREFIX = "mysql+pymysql://" DOMAIN = "recorder" CONF_DB_INTEGRITY_CHECK = "db_integrity_check" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index c0f19f2e8640fe4a79c7bb914cb59eeb9faec57f..0ef8c20d5c7a6cdc31a34e13e315bdf99098e744 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -26,18 +26,18 @@ from homeassistant.components import persistent_notification from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_FINAL_WRITE, - EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, MATCH_ALL, ) -from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import ( async_track_time_change, async_track_time_interval, async_track_utc_time_change, ) from homeassistant.helpers.json import JSON_ENCODE_EXCEPTIONS +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import UNDEFINED, UndefinedType import homeassistant.util.dt as dt_util @@ -46,7 +46,10 @@ from .const import ( DB_WORKER_PREFIX, DOMAIN, KEEPALIVE_TIME, + MARIADB_PYMYSQL_URL_PREFIX, + MARIADB_URL_PREFIX, MAX_QUEUE_BACKLOG, + MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, SQLITE_URL_PREFIX, SupportedDialect, @@ -58,7 +61,9 @@ from .db_schema import ( Events, StateAttributes, States, + Statistics, StatisticsRuns, + StatisticsShortTerm, ) from .executor import DBInterruptibleThreadPoolExecutor from .models import ( @@ -402,7 +407,7 @@ class Recorder(threading.Thread): await self.hass.async_add_executor_job(self.join) @callback - def _async_hass_started(self, event: Event) -> None: + def _async_hass_started(self, hass: HomeAssistant) -> None: """Notify that hass has started.""" self._hass_started.set_result(None) @@ -412,10 +417,7 @@ class Recorder(threading.Thread): bus = self.hass.bus bus.async_listen_once(EVENT_HOMEASSISTANT_FINAL_WRITE, self._empty_queue) bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) - if self.hass.state == CoreState.running: - self._hass_started.set_result(None) - return - bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self._async_hass_started) + async_at_started(self.hass, self._async_hass_started) @callback def async_connection_failed(self) -> None: @@ -531,10 +533,13 @@ class Recorder(threading.Thread): @callback def async_import_statistics( - self, metadata: StatisticMetaData, stats: Iterable[StatisticData] + self, + metadata: StatisticMetaData, + stats: Iterable[StatisticData], + table: type[Statistics | StatisticsShortTerm], ) -> None: """Schedule import of statistics.""" - self.queue_task(ImportStatisticsTask(metadata, stats)) + self.queue_task(ImportStatisticsTask(metadata, stats, table)) @callback def _async_setup_periodic_tasks(self) -> None: @@ -585,30 +590,41 @@ class Recorder(threading.Thread): def run(self) -> None: """Start processing events to save.""" - current_version = self._setup_recorder() + setup_result = self._setup_recorder() + + if not setup_result: + # Give up if we could not connect + self.hass.add_job(self.async_connection_failed) + return - if current_version is None: + schema_status = migration.validate_db_schema(self.hass, self.get_session) + if schema_status is None: + # Give up if we could not validate the schema self.hass.add_job(self.async_connection_failed) return + self.schema_version = schema_status.current_version - self.schema_version = current_version + schema_is_valid = migration.schema_is_valid(schema_status) - schema_is_current = migration.schema_is_current(current_version) - if schema_is_current: + if schema_is_valid: self._setup_run() else: self.migration_in_progress = True - self.migration_is_live = migration.live_migration(current_version) + self.migration_is_live = migration.live_migration(schema_status) self.hass.add_job(self.async_connection_success) - if self.migration_is_live or schema_is_current: + if self.migration_is_live or schema_is_valid: # If the migrate is live or the schema is current, we need to # wait for startup to complete. If its not live, we need to continue # on. self.hass.add_job(self.async_set_db_ready) - # If shutdown happened before Home Assistant finished starting + + # We wait to start a live migration until startup has finished + # since it can be cpu intensive and we do not want it to compete + # with startup which is also cpu intensive if self._wait_startup_or_shutdown() is SHUTDOWN_TASK: + # Shutdown happened before Home Assistant finished starting self.migration_in_progress = False # Make sure we cleanly close the run if # we restart before startup finishes @@ -616,11 +632,8 @@ class Recorder(threading.Thread): self.hass.add_job(self.async_set_db_ready) return - # We wait to start the migration until startup has finished - # since it can be cpu intensive and we do not want it to compete - # with startup which is also cpu intensive - if not schema_is_current: - if self._migrate_schema_and_setup_run(current_version): + if not schema_is_valid: + if self._migrate_schema_and_setup_run(schema_status): self.schema_version = SCHEMA_VERSION if not self._event_listener: # If the schema migration takes so long that the end @@ -685,14 +698,14 @@ class Recorder(threading.Thread): # happens to rollback and recover self._reopen_event_session() - def _setup_recorder(self) -> None | int: - """Create connect to the database and get the schema version.""" + def _setup_recorder(self) -> bool: + """Create a connection to the database.""" tries = 1 while tries <= self.db_max_retries: try: self._setup_connection() - return migration.get_schema_version(self.get_session) + return migration.initialize_database(self.get_session) except UnsupportedDialect: break except Exception as err: # pylint: disable=broad-except @@ -704,14 +717,16 @@ class Recorder(threading.Thread): tries += 1 time.sleep(self.db_retry_wait) - return None + return False @callback def _async_migration_started(self) -> None: """Set the migration started event.""" self.async_migration_event.set() - def _migrate_schema_and_setup_run(self, current_version: int) -> bool: + def _migrate_schema_and_setup_run( + self, schema_status: migration.SchemaValidationStatus + ) -> bool: """Migrate schema to the latest version.""" persistent_notification.create( self.hass, @@ -723,7 +738,7 @@ class Recorder(threading.Thread): try: migration.migrate_schema( - self, self.hass, self.engine, self.get_session, current_version + self, self.hass, self.engine, self.get_session, schema_status ) except exc.DatabaseError as err: if self._handle_database_error(err): @@ -1114,15 +1129,26 @@ class Recorder(threading.Thread): kwargs["pool_reset_on_return"] = None elif self.db_url.startswith(SQLITE_URL_PREFIX): kwargs["poolclass"] = RecorderPool - elif self.db_url.startswith(MYSQLDB_URL_PREFIX): - # If they have configured MySQLDB but don't have - # the MySQLDB module installed this will throw - # an ImportError which we suppress here since - # sqlalchemy will give them a better error when - # it tried to import it below. - with contextlib.suppress(ImportError): - kwargs["connect_args"] = {"conv": build_mysqldb_conv()} - else: + elif self.db_url.startswith( + ( + MARIADB_URL_PREFIX, + MARIADB_PYMYSQL_URL_PREFIX, + MYSQLDB_URL_PREFIX, + MYSQLDB_PYMYSQL_URL_PREFIX, + ) + ): + kwargs["connect_args"] = {"charset": "utf8mb4"} + if self.db_url.startswith((MARIADB_URL_PREFIX, MYSQLDB_URL_PREFIX)): + # If they have configured MySQLDB but don't have + # the MySQLDB module installed this will throw + # an ImportError which we suppress here since + # sqlalchemy will give them a better error when + # it tried to import it below. + with contextlib.suppress(ImportError): + kwargs["connect_args"]["conv"] = build_mysqldb_conv() + + # Disable extended logging for non SQLite databases + if not self.db_url.startswith(SQLITE_URL_PREFIX): kwargs["echo"] = False if self._using_file_sqlite: diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index d76f89068d0fb15dc1510c85826da7f1a31e8d5b..d2373d96aeb589209fee15492a2c121ad8ee0889 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -511,10 +511,10 @@ class RecorderRuns(Base): # type: ignore[misc,valid-type] __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)) + start = Column(DATETIME_TYPE, default=dt_util.utcnow) + end = Column(DATETIME_TYPE) closed_incorrect = Column(Boolean, default=False) - created = Column(DateTime(timezone=True), default=dt_util.utcnow) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -561,7 +561,7 @@ class SchemaChanges(Base): # type: ignore[misc,valid-type] __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) + changed = Column(DATETIME_TYPE, default=dt_util.utcnow) def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -578,7 +578,7 @@ class StatisticsRuns(Base): # type: ignore[misc,valid-type] __tablename__ = TABLE_STATISTICS_RUNS run_id = Column(Integer, Identity(), primary_key=True) - start = Column(DateTime(timezone=True), index=True) + start = Column(DATETIME_TYPE, index=True) def __repr__(self) -> str: """Return string representation of instance for debugging.""" diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 45db64e0097cbd7099a8c8c6e1e2230d00141003..72e9916b6a77f221c666f16aa74eee7c763d5d32 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -1,7 +1,7 @@ """Provide pre-made queries on top of the recorder component.""" from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable, Collection, Iterable import json from typing import Any @@ -81,13 +81,13 @@ class Filters: def __init__(self) -> None: """Initialise the include and exclude filters.""" - self.excluded_entities: Iterable[str] = [] - self.excluded_domains: Iterable[str] = [] - self.excluded_entity_globs: Iterable[str] = [] + self.excluded_entities: Collection[str] = [] + self.excluded_domains: Collection[str] = [] + self.excluded_entity_globs: Collection[str] = [] - self.included_entities: Iterable[str] = [] - self.included_domains: Iterable[str] = [] - self.included_entity_globs: Iterable[str] = [] + self.included_entities: Collection[str] = [] + self.included_domains: Collection[str] = [] + self.included_entity_globs: Collection[str] = [] def __repr__(self) -> str: """Return human readable excludes/includes.""" diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 19a22b2a1e3ff7c406f49c6a762b201f867716cf..afdabfd6d0176fdc1dcec5aaa96ea7e9f9435df6 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.41", "fnvhash==0.1.0"], + "requirements": ["sqlalchemy==1.4.42", "fnvhash==0.1.0"], "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 f82ec7ba1eb2260f5d66729968e21fb3d4160473..22a3b382c7dd19cd500982343b86b9bc96602065 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1,9 +1,12 @@ """Schema migration helpers.""" +from __future__ import annotations + from collections.abc import Callable, Iterable import contextlib +from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any, cast +from typing import TYPE_CHECKING import sqlalchemy from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text @@ -40,6 +43,9 @@ from .statistics import ( ) from .util import session_scope +if TYPE_CHECKING: + from . import Recorder + LIVE_MIGRATION_MIN_SCHEMA_VERSION = 0 _LOGGER = logging.getLogger(__name__) @@ -56,47 +62,73 @@ def raise_if_exception_missing_str(ex: Exception, match_substrs: Iterable[str]) raise ex -def get_schema_version(session_maker: Callable[[], Session]) -> int: +def _get_schema_version(session: Session) -> int | None: """Get the schema version.""" - with session_scope(session=session_maker()) as session: - res = ( - session.query(SchemaChanges) - .order_by(SchemaChanges.change_id.desc()) - .first() - ) - current_version = getattr(res, "schema_version", None) + res = session.query(SchemaChanges).order_by(SchemaChanges.change_id.desc()).first() + return getattr(res, "schema_version", None) - if current_version is None: - current_version = _inspect_schema_version(session) - _LOGGER.debug( - "No schema version found. Inspected version: %s", current_version - ) - return cast(int, current_version) +def get_schema_version(session_maker: Callable[[], Session]) -> int | None: + """Get the schema version.""" + try: + with session_scope(session=session_maker()) as session: + return _get_schema_version(session) + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Error when determining DB schema version: %s", err) + return None + + +@dataclass +class SchemaValidationStatus: + """Store schema validation status.""" + current_version: int -def schema_is_current(current_version: int) -> bool: + +def _schema_is_current(current_version: int) -> bool: """Check if the schema is current.""" return current_version == SCHEMA_VERSION -def live_migration(current_version: int) -> bool: +def schema_is_valid(schema_status: SchemaValidationStatus) -> bool: + """Check if the schema is valid.""" + return _schema_is_current(schema_status.current_version) + + +def validate_db_schema( + hass: HomeAssistant, session_maker: Callable[[], Session] +) -> SchemaValidationStatus | None: + """Check if the schema is valid. + + This checks that the schema is the current version as well as for some common schema + errors caused by manual migration between database engines, for example importing an + SQLite database to MariaDB. + """ + current_version = get_schema_version(session_maker) + if current_version is None: + return None + + return SchemaValidationStatus(current_version) + + +def live_migration(schema_status: SchemaValidationStatus) -> bool: """Check if live migration is possible.""" - return current_version >= LIVE_MIGRATION_MIN_SCHEMA_VERSION + return schema_status.current_version >= LIVE_MIGRATION_MIN_SCHEMA_VERSION def migrate_schema( - instance: Any, + instance: Recorder, hass: HomeAssistant, engine: Engine, session_maker: Callable[[], Session], - current_version: int, + schema_status: SchemaValidationStatus, ) -> None: """Check if the schema needs to be upgraded.""" + current_version = schema_status.current_version _LOGGER.warning("Database is about to upgrade. Schema version: %s", current_version) db_ready = False for version in range(current_version, SCHEMA_VERSION): - if live_migration(version) and not db_ready: + if live_migration(SchemaValidationStatus(version)) and not db_ready: db_ready = True instance.migration_is_live = True hass.add_job(instance.async_set_db_ready) @@ -750,13 +782,18 @@ def _apply_update( # noqa: C901 elif new_version == 30: # This added a column to the statistics_meta table, removed again before # release of HA Core 2022.10.0 + # SQLite 3.31.0 does not support dropping columns. + # Once we require SQLite >= 3.35.5, we should drop the column: + # ALTER TABLE statistics_meta DROP COLUMN state_unit_of_measurement pass else: raise ValueError(f"No schema migration defined for version {new_version}") -def _inspect_schema_version(session: Session) -> int: - """Determine the schema version by inspecting the db structure. +def _initialize_database(session: Session) -> bool: + """Initialize a new database, or a database created before introducing schema changes. + + The function determines the schema version by inspecting the db structure. When the schema version is not present in the db, either db was just created with the correct schema, or this is a db created before schema @@ -772,9 +809,22 @@ def _inspect_schema_version(session: Session) -> int: # Schema addition from version 1 detected. New DB. session.add(StatisticsRuns(start=get_start_time())) session.add(SchemaChanges(schema_version=SCHEMA_VERSION)) - return SCHEMA_VERSION + return True # Version 1 schema changes not found, this db needs to be migrated. current_version = SchemaChanges(schema_version=0) session.add(current_version) - return cast(int, current_version.schema_version) + return True + + +def initialize_database(session_maker: Callable[[], Session]) -> bool: + """Initialize a new database, or a database created before introducing schema changes.""" + try: + with session_scope(session=session_maker()) as session: + if _get_schema_version(session) is not None: + return True + return _initialize_database(session) + + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Error when initialise database: %s", err) + return False diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index a8579df834c36e85eef170e440623c293e05ece0..35a8623a1c1f82987506bb2da9df0122451025a0 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -128,6 +128,7 @@ class MutexPool(StaticPool): # type: ignore[misc] if DEBUG_MUTEX_POOL: _LOGGER.debug("%s wait conn%s", threading.current_thread().name, trace_msg) + # pylint: disable-next=consider-using-with got_lock = MutexPool.pool_lock.acquire(timeout=1) if not got_lock: raise SQLAlchemyError diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7ba5c5f8c73693fc86f1fb1530f3ba796e22d3b6..8a744fd4daabd88c1cb7911eaa6aa10602a4c0a0 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -582,9 +582,7 @@ def _compile_hourly_statistics_summary_mean_stmt( ) -def compile_hourly_statistics( - instance: Recorder, session: Session, start: datetime -) -> None: +def _compile_hourly_statistics(session: Session, start: datetime) -> None: """Compile hourly statistics. This will summarize 5-minute statistics for one hour: @@ -700,7 +698,7 @@ def compile_statistics(instance: Recorder, start: datetime) -> bool: if start.minute == 55: # A full hour is ready, summarize it - compile_hourly_statistics(instance, session, start) + _compile_hourly_statistics(session, start) session.add(StatisticsRuns(start=start)) @@ -776,7 +774,7 @@ def _update_statistics( def _generate_get_metadata_stmt( - statistic_ids: list[str] | tuple[str] | None = None, + statistic_ids: list[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> StatementLambdaElement: @@ -794,10 +792,9 @@ def _generate_get_metadata_stmt( def get_metadata_with_session( - hass: HomeAssistant, session: Session, *, - statistic_ids: list[str] | tuple[str] | None = None, + statistic_ids: list[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: @@ -834,14 +831,13 @@ def get_metadata_with_session( def get_metadata( hass: HomeAssistant, *, - statistic_ids: list[str] | tuple[str] | None = None, + statistic_ids: list[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: """Return metadata for statistic_ids.""" with session_scope(hass=hass) as session: return get_metadata_with_session( - hass, session, statistic_ids=statistic_ids, statistic_type=statistic_type, @@ -882,7 +878,7 @@ def update_statistics_metadata( def list_statistic_ids( hass: HomeAssistant, - statistic_ids: list[str] | tuple[str] | None = None, + statistic_ids: list[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, ) -> list[dict]: """Return all statistic_ids (or filtered one) and unit of measurement. @@ -896,7 +892,7 @@ def list_statistic_ids( # Query the database with session_scope(hass=hass) as session: metadata = get_metadata_with_session( - hass, session, statistic_type=statistic_type, statistic_ids=statistic_ids + session, statistic_type=statistic_type, statistic_ids=statistic_ids ) result = { @@ -1018,6 +1014,35 @@ def _reduce_statistics_per_day( return _reduce_statistics(stats, same_day, day_start_end, timedelta(days=1)) +def same_week(time1: datetime, time2: datetime) -> bool: + """Return True if time1 and time2 are in the same year and week.""" + date1 = dt_util.as_local(time1).date() + date2 = dt_util.as_local(time2).date() + return (date1.year, date1.isocalendar().week) == ( + date2.year, + date2.isocalendar().week, + ) + + +def week_start_end(time: datetime) -> tuple[datetime, datetime]: + """Return the start and end of the period (week) time is within.""" + time_local = dt_util.as_local(time) + start_local = time_local.replace( + hour=0, minute=0, second=0, microsecond=0 + ) - timedelta(days=time_local.weekday()) + start = dt_util.as_utc(start_local) + end = dt_util.as_utc(start_local + timedelta(days=7)) + return (start, end) + + +def _reduce_statistics_per_week( + stats: dict[str, list[dict[str, Any]]], +) -> dict[str, list[dict[str, Any]]]: + """Reduce hourly statistics to weekly statistics.""" + + return _reduce_statistics(stats, same_week, week_start_end, timedelta(days=7)) + + def same_month(time1: datetime, time2: datetime) -> bool: """Return True if time1 and time2 are in the same year and month.""" date1 = dt_util.as_local(time1).date() @@ -1088,16 +1113,387 @@ def _statistics_during_period_stmt_short_term( return stmt +def _get_max_mean_min_statistic_in_sub_period( + session: Session, + result: dict[str, float], + start_time: datetime | None, + end_time: datetime | None, + table: type[Statistics | StatisticsShortTerm], + types: set[str], + metadata_id: int, +) -> None: + """Return max, mean and min during the period.""" + # Calculate max, mean, min + columns = [] + if "max" in types: + columns.append(func.max(table.max)) + if "mean" in types: + columns.append(func.avg(table.mean)) + columns.append(func.count(table.mean)) + if "min" in types: + columns.append(func.min(table.min)) + stmt = lambda_stmt(lambda: select(columns).filter(table.metadata_id == metadata_id)) + if start_time is not None: + stmt += lambda q: q.filter(table.start >= start_time) + if end_time is not None: + stmt += lambda q: q.filter(table.start < end_time) + stats = execute_stmt_lambda_element(session, stmt) + if "max" in types and stats and (new_max := stats[0].max) is not None: + old_max = result.get("max") + result["max"] = max(new_max, old_max) if old_max is not None else new_max + if "mean" in types and stats and stats[0].avg is not None: + duration = stats[0].count * table.duration.total_seconds() + result["duration"] = result.get("duration", 0.0) + duration + result["mean_acc"] = result.get("mean_acc", 0.0) + stats[0].avg * duration + if "min" in types and stats and (new_min := stats[0].min) is not None: + old_min = result.get("min") + result["min"] = min(new_min, old_min) if old_min is not None else new_min + + +def _get_max_mean_min_statistic( + session: Session, + head_start_time: datetime | None, + head_end_time: datetime | None, + main_start_time: datetime | None, + main_end_time: datetime | None, + tail_start_time: datetime | None, + tail_end_time: datetime | None, + tail_only: bool, + metadata_id: int, + types: set[str], +) -> dict[str, float | None]: + """Return max, mean and min during the period. + + The mean is a time weighted average, combining hourly and 5-minute statistics if + necessary. + """ + max_mean_min: dict[str, float] = {} + result: dict[str, float | None] = {} + + if tail_start_time is not None: + # Calculate max, mean, min + _get_max_mean_min_statistic_in_sub_period( + session, + max_mean_min, + tail_start_time, + tail_end_time, + StatisticsShortTerm, + types, + metadata_id, + ) + + if not tail_only: + _get_max_mean_min_statistic_in_sub_period( + session, + max_mean_min, + main_start_time, + main_end_time, + Statistics, + types, + metadata_id, + ) + + if head_start_time is not None: + _get_max_mean_min_statistic_in_sub_period( + session, + max_mean_min, + head_start_time, + head_end_time, + StatisticsShortTerm, + types, + metadata_id, + ) + + if "max" in types: + result["max"] = max_mean_min.get("max") + if "mean" in types: + if "mean_acc" not in max_mean_min: + result["mean"] = None + else: + result["mean"] = max_mean_min["mean_acc"] / max_mean_min["duration"] + if "min" in types: + result["min"] = max_mean_min.get("min") + return result + + +def _get_oldest_sum_statistic( + session: Session, + head_start_time: datetime | None, + main_start_time: datetime | None, + tail_start_time: datetime | None, + tail_only: bool, + metadata_id: int, +) -> float | None: + """Return the oldest non-NULL sum during the period.""" + + def _get_oldest_sum_statistic_in_sub_period( + session: Session, + start_time: datetime | None, + table: type[Statistics | StatisticsShortTerm], + metadata_id: int, + ) -> tuple[float | None, datetime | None]: + """Return the oldest non-NULL sum during the period.""" + stmt = lambda_stmt( + lambda: select(table.sum, table.start) + .filter(table.metadata_id == metadata_id) + .filter(table.sum.is_not(None)) + .order_by(table.start.asc()) + .limit(1) + ) + if start_time is not None: + start_time = start_time + table.duration - timedelta.resolution + if table == StatisticsShortTerm: + minutes = start_time.minute - start_time.minute % 5 + period = start_time.replace(minute=minutes, second=0, microsecond=0) + else: + period = start_time.replace(minute=0, second=0, microsecond=0) + prev_period = period - table.duration + stmt += lambda q: q.filter(table.start == prev_period) + stats = execute_stmt_lambda_element(session, stmt) + return ( + (stats[0].sum, process_timestamp(stats[0].start)) if stats else (None, None) + ) + + oldest_start: datetime | None + oldest_sum: float | None = None + + if head_start_time is not None: + oldest_sum, oldest_start = _get_oldest_sum_statistic_in_sub_period( + session, head_start_time, StatisticsShortTerm, metadata_id + ) + if ( + oldest_start is not None + and oldest_start < head_start_time + and oldest_sum is not None + ): + return oldest_sum + + if not tail_only: + assert main_start_time is not None + oldest_sum, oldest_start = _get_oldest_sum_statistic_in_sub_period( + session, main_start_time, Statistics, metadata_id + ) + if ( + oldest_start is not None + and oldest_start < main_start_time + and oldest_sum is not None + ): + return oldest_sum + return 0 + + if tail_start_time is not None: + oldest_sum, oldest_start = _get_oldest_sum_statistic_in_sub_period( + session, tail_start_time, StatisticsShortTerm, metadata_id + ) + if ( + oldest_start is not None + and oldest_start < tail_start_time + and oldest_sum is not None + ): + return oldest_sum + + return 0 + + +def _get_newest_sum_statistic( + session: Session, + head_start_time: datetime | None, + head_end_time: datetime | None, + main_start_time: datetime | None, + main_end_time: datetime | None, + tail_start_time: datetime | None, + tail_end_time: datetime | None, + tail_only: bool, + metadata_id: int, +) -> float | None: + """Return the newest non-NULL sum during the period.""" + + def _get_newest_sum_statistic_in_sub_period( + session: Session, + start_time: datetime | None, + end_time: datetime | None, + table: type[Statistics | StatisticsShortTerm], + metadata_id: int, + ) -> float | None: + """Return the newest non-NULL sum during the period.""" + stmt = lambda_stmt( + lambda: select( + table.sum, + ) + .filter(table.metadata_id == metadata_id) + .filter(table.sum.is_not(None)) + .order_by(table.start.desc()) + .limit(1) + ) + if start_time is not None: + stmt += lambda q: q.filter(table.start >= start_time) + if end_time is not None: + stmt += lambda q: q.filter(table.start < end_time) + stats = execute_stmt_lambda_element(session, stmt) + + return stats[0].sum if stats else None + + newest_sum: float | None = None + + if tail_start_time is not None: + newest_sum = _get_newest_sum_statistic_in_sub_period( + session, tail_start_time, tail_end_time, StatisticsShortTerm, metadata_id + ) + if newest_sum is not None: + return newest_sum + + if not tail_only: + newest_sum = _get_newest_sum_statistic_in_sub_period( + session, main_start_time, main_end_time, Statistics, metadata_id + ) + if newest_sum is not None: + return newest_sum + + if head_start_time is not None: + newest_sum = _get_newest_sum_statistic_in_sub_period( + session, head_start_time, head_end_time, StatisticsShortTerm, metadata_id + ) + + return newest_sum + + +def statistic_during_period( + hass: HomeAssistant, + start_time: datetime | None, + end_time: datetime | None, + statistic_id: str, + types: set[str] | None, + units: dict[str, str] | None, +) -> dict[str, Any]: + """Return a statistic data point for the UTC period start_time - end_time.""" + metadata = None + + if not types: + types = {"max", "mean", "min", "change"} + + result: dict[str, Any] = {} + + # To calculate the summary, data from the statistics (hourly) and short_term_statistics + # (5 minute) tables is combined + # - The short term statistics table is used for the head and tail of the period, + # if the period it doesn't start or end on a full hour + # - The statistics table is used for the remainder of the time + now = dt_util.utcnow() + if end_time is not None and end_time > now: + end_time = now + + tail_only = ( + start_time is not None + and end_time is not None + and end_time - start_time < timedelta(hours=1) + ) + + # Calculate the head period + head_start_time: datetime | None = None + head_end_time: datetime | None = None + if not tail_only and start_time is not None and start_time.minute: + head_start_time = start_time + head_end_time = start_time.replace( + minute=0, second=0, microsecond=0 + ) + timedelta(hours=1) + + # Calculate the tail period + tail_start_time: datetime | None = None + tail_end_time: datetime | None = None + if end_time is None: + tail_start_time = now.replace(minute=0, second=0, microsecond=0) + elif end_time.minute: + tail_start_time = ( + start_time + if tail_only + else end_time.replace(minute=0, second=0, microsecond=0) + ) + tail_end_time = end_time + + # Calculate the main period + main_start_time: datetime | None = None + main_end_time: datetime | None = None + if not tail_only: + main_start_time = start_time if head_end_time is None else head_end_time + main_end_time = end_time if tail_start_time is None else tail_start_time + + with session_scope(hass=hass) as session: + # Fetch metadata for the given statistic_id + metadata = get_metadata_with_session(session, statistic_ids=[statistic_id]) + if not metadata: + return result + + metadata_id = metadata[statistic_id][0] + + if not types.isdisjoint({"max", "mean", "min"}): + result = _get_max_mean_min_statistic( + session, + head_start_time, + head_end_time, + main_start_time, + main_end_time, + tail_start_time, + tail_end_time, + tail_only, + metadata_id, + types, + ) + + if "change" in types: + oldest_sum: float | None + if start_time is None: + oldest_sum = 0.0 + else: + oldest_sum = _get_oldest_sum_statistic( + session, + head_start_time, + main_start_time, + tail_start_time, + tail_only, + metadata_id, + ) + newest_sum = _get_newest_sum_statistic( + session, + head_start_time, + head_end_time, + main_start_time, + main_end_time, + tail_start_time, + tail_end_time, + tail_only, + metadata_id, + ) + # Calculate the difference between the oldest and newest sum + if oldest_sum is not None and newest_sum is not None: + result["change"] = newest_sum - oldest_sum + else: + result["change"] = None + + def no_conversion(val: float | None) -> float | None: + """Return val.""" + return val + + state_unit = unit = metadata[statistic_id][1]["unit_of_measurement"] + if state := hass.states.get(statistic_id): + state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if unit is not None: + convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) + else: + convert = no_conversion + + return {key: convert(value) for key, value in result.items()} + + def statistics_during_period( hass: HomeAssistant, start_time: datetime, end_time: datetime | None = None, statistic_ids: list[str] | None = None, - period: Literal["5minute", "day", "hour", "month"] = "hour", + period: Literal["5minute", "day", "hour", "week", "month"] = "hour", start_time_as_datetime: bool = False, units: dict[str, str] | None = None, ) -> dict[str, list[dict[str, Any]]]: - """Return statistics during UTC period start_time - end_time for the statistic_ids. + """Return statistic data points during UTC period start_time - end_time. If end_time is omitted, returns statistics newer than or equal to start_time. If statistic_ids is omitted, returns statistics for all statistics ids. @@ -1105,7 +1501,7 @@ def statistics_during_period( metadata = None with session_scope(hass=hass) as session: # Fetch metadata for the given (or all) statistic_ids - metadata = get_metadata_with_session(hass, session, statistic_ids=statistic_ids) + metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) if not metadata: return {} @@ -1126,7 +1522,7 @@ def statistics_during_period( if not stats: return {} # Return statistics combined with metadata - if period not in ("day", "month"): + if period not in ("day", "week", "month"): return _sorted_statistics_to_dict( hass, session, @@ -1156,6 +1552,9 @@ def statistics_during_period( if period == "day": return _reduce_statistics_per_day(result) + if period == "week": + return _reduce_statistics_per_week(result) + return _reduce_statistics_per_month(result) @@ -1196,7 +1595,7 @@ def _get_last_statistics( statistic_ids = [statistic_id] with session_scope(hass=hass) as session: # Fetch metadata for the given statistic_id - metadata = get_metadata_with_session(hass, session, statistic_ids=statistic_ids) + metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) if not metadata: return {} metadata_id = metadata[statistic_id][0] @@ -1280,9 +1679,7 @@ def get_latest_short_term_statistics( with session_scope(hass=hass) as session: # Fetch metadata for the given statistic_ids if not metadata: - metadata = get_metadata_with_session( - hass, session, statistic_ids=statistic_ids - ) + metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) if not metadata: return {} metadata_ids = [ @@ -1467,7 +1864,7 @@ def _async_import_statistics( statistic["last_reset"] = dt_util.as_utc(last_reset) # Insert job in recorder's queue - get_instance(hass).async_import_statistics(metadata, statistics) + get_instance(hass).async_import_statistics(metadata, statistics, Statistics) @callback @@ -1535,7 +1932,7 @@ def _filter_unique_constraint_integrity_error( and err.orig.pgcode == "23505" ): ignore = True - if dialect_name == "mysql" and hasattr(err.orig, "args"): + if dialect_name == SupportedDialect.MYSQL and hasattr(err.orig, "args"): with contextlib.suppress(TypeError): if err.orig.args[0] == 1062: ignore = True @@ -1557,6 +1954,7 @@ def import_statistics( instance: Recorder, metadata: StatisticMetaData, statistics: Iterable[StatisticData], + table: type[Statistics | StatisticsShortTerm], ) -> bool: """Process an import_statistics job.""" @@ -1565,16 +1963,16 @@ def import_statistics( exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: old_metadata_dict = get_metadata_with_session( - instance.hass, session, statistic_ids=[metadata["statistic_id"]] + session, statistic_ids=[metadata["statistic_id"]] ) metadata_id = _update_or_add_metadata(session, metadata, old_metadata_dict) for stat in statistics: if stat_id := _statistics_exists( - session, Statistics, metadata_id, stat["start"] + session, table, metadata_id, stat["start"] ): - _update_statistics(session, Statistics, stat_id, stat) + _update_statistics(session, table, stat_id, stat) else: - _insert_statistics(session, Statistics, metadata_id, stat) + _insert_statistics(session, table, metadata_id, stat) return True @@ -1590,9 +1988,7 @@ def adjust_statistics( """Process an add_statistics job.""" with session_scope(session=instance.get_session()) as session: - metadata = get_metadata_with_session( - instance.hass, session, statistic_ids=(statistic_id,) - ) + metadata = get_metadata_with_session(session, statistic_ids=[statistic_id]) if statistic_id not in metadata: return True @@ -1652,9 +2048,9 @@ def change_statistics_unit( ) -> None: """Change statistics unit for a statistic_id.""" with session_scope(session=instance.get_session()) as session: - metadata = get_metadata_with_session( - instance.hass, session, statistic_ids=(statistic_id,) - ).get(statistic_id) + metadata = get_metadata_with_session(session, statistic_ids=[statistic_id]).get( + statistic_id + ) # Guard against the statistics being removed or updated before the # change_statistics_unit job executes diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 4fa3a3cc40c23114fbad78a195fc3030d122cd58..1b8e03ebf178f90602c05137369fb167f3f5a221 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -14,6 +14,7 @@ from homeassistant.helpers.typing import UndefinedType from . import purge, statistics from .const import DOMAIN, EXCLUDE_ATTRIBUTES +from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData from .util import periodic_db_cleanups @@ -147,13 +148,18 @@ class ImportStatisticsTask(RecorderTask): metadata: StatisticMetaData statistics: Iterable[StatisticData] + table: type[Statistics | StatisticsShortTerm] def run(self, instance: Recorder) -> None: """Run statistics task.""" - if statistics.import_statistics(instance, self.metadata, self.statistics): + if statistics.import_statistics( + instance, self.metadata, self.statistics, self.table + ): return # Schedule a new statistics task if this one didn't finish - instance.queue_task(ImportStatisticsTask(self.metadata, self.statistics)) + instance.queue_task( + ImportStatisticsTask(self.metadata, self.statistics, self.table) + ) @dataclass diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 139e73199ed166e646f1a16b2bfbde0b42441580..8ee9a4e0401a1ea15d32f95a6fc953ae71f5bff2 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -1,7 +1,7 @@ """SQLAlchemy util functions.""" from __future__ import annotations -from collections.abc import Callable, Generator, Iterable +from collections.abc import Callable, Generator from contextlib import contextmanager from datetime import date, datetime, timedelta import functools @@ -180,7 +180,7 @@ def execute_stmt_lambda_element( start_time: datetime | None = None, end_time: datetime | None = None, yield_per: int | None = DEFAULT_YIELD_STATES_ROWS, -) -> Iterable[Row]: +) -> list[Row]: """Execute a StatementLambdaElement. If the time window passed is greater than one day @@ -509,6 +509,7 @@ def retryable_database_job( assert instance.engine is not None if ( instance.engine.dialect.name == SupportedDialect.MYSQL + and err.orig and err.orig.args[0] in RETRYABLE_MYSQL_ERRORS ): _LOGGER.info( diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index c583577ec8fc7ca72b72f00bf798842bc598a061..9b2ef417755c40e795d9bd708968e247df971596 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -1,15 +1,16 @@ """The Recorder websocket API.""" from __future__ import annotations -from datetime import datetime as dt +from datetime import datetime as dt, timedelta import logging -from typing import Literal +from typing import Any, Literal import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.websocket_api import messages from homeassistant.core import HomeAssistant, callback, valid_entity_id +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import JSON_DUMP from homeassistant.util import dt as dt_util @@ -31,6 +32,7 @@ from .statistics import ( async_change_statistics_unit, async_import_statistics, list_statistic_ids, + statistic_during_period, statistics_during_period, validate_statistics, ) @@ -47,6 +49,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_backup_start) websocket_api.async_register_command(hass, ws_change_statistics_unit) websocket_api.async_register_command(hass, ws_clear_statistics) + websocket_api.async_register_command(hass, ws_get_statistic_during_period) websocket_api.async_register_command(hass, ws_get_statistics_during_period) websocket_api.async_register_command(hass, ws_get_statistics_metadata) websocket_api.async_register_command(hass, ws_list_statistic_ids) @@ -56,13 +59,156 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_validate_statistics) +def _ws_get_statistic_during_period( + hass: HomeAssistant, + msg_id: int, + start_time: dt | None, + end_time: dt | None, + statistic_id: str, + types: set[str] | None, + units: dict[str, str], +) -> str: + """Fetch statistics and convert them to json in the executor.""" + return JSON_DUMP( + messages.result_message( + msg_id, + statistic_during_period( + hass, start_time, end_time, statistic_id, types, units=units + ), + ) + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "recorder/statistic_during_period", + vol.Exclusive("calendar", "period"): vol.Schema( + { + vol.Required("period"): vol.Any("hour", "day", "week", "month", "year"), + vol.Optional("offset"): int, + } + ), + vol.Exclusive("fixed_period", "period"): vol.Schema( + { + vol.Optional("start_time"): str, + vol.Optional("end_time"): str, + } + ), + vol.Exclusive("rolling_window", "period"): vol.Schema( + { + vol.Required("duration"): cv.time_period_dict, + vol.Optional("offset"): cv.time_period_dict, + } + ), + vol.Optional("statistic_id"): str, + vol.Optional("types"): vol.All([str], vol.Coerce(set)), + vol.Optional("units"): vol.Schema( + { + vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), + vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), + vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), + vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS), + vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS), + vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional("volume"): vol.In(VolumeConverter.VALID_UNITS), + } + ), + } +) +@websocket_api.async_response +async def ws_get_statistic_during_period( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle statistics websocket command.""" + if ("start_time" in msg or "end_time" in msg) and "duration" in msg: + raise HomeAssistantError + if "offset" in msg and "duration" not in msg: + raise HomeAssistantError + + start_time = None + end_time = None + + if "calendar" in msg: + calendar_period = msg["calendar"]["period"] + start_of_day = dt_util.start_of_local_day() + offset = msg["calendar"].get("offset", 0) + if calendar_period == "hour": + start_time = dt_util.now().replace(minute=0, second=0, microsecond=0) + start_time += timedelta(hours=offset) + end_time = start_time + timedelta(hours=1) + elif calendar_period == "day": + start_time = start_of_day + start_time += timedelta(days=offset) + end_time = start_time + timedelta(days=1) + elif calendar_period == "week": + start_time = start_of_day - timedelta(days=start_of_day.weekday()) + start_time += timedelta(days=offset * 7) + end_time = start_time + timedelta(weeks=1) + elif calendar_period == "month": + start_time = start_of_day.replace(day=28) + # This works for up to 48 months of offset + start_time = (start_time + timedelta(days=offset * 31)).replace(day=1) + end_time = (start_time + timedelta(days=31)).replace(day=1) + else: # calendar_period = "year" + start_time = start_of_day.replace(month=12, day=31) + # This works for 100+ years of offset + start_time = (start_time + timedelta(days=offset * 366)).replace( + month=1, day=1 + ) + end_time = (start_time + timedelta(days=365)).replace(day=1) + + start_time = dt_util.as_utc(start_time) + end_time = dt_util.as_utc(end_time) + + elif "fixed_period" in msg: + if start_time_str := msg["fixed_period"].get("start_time"): + if start_time := dt_util.parse_datetime(start_time_str): + start_time = dt_util.as_utc(start_time) + else: + connection.send_error( + msg["id"], "invalid_start_time", "Invalid start_time" + ) + return + + if end_time_str := msg["fixed_period"].get("end_time"): + if end_time := dt_util.parse_datetime(end_time_str): + end_time = dt_util.as_utc(end_time) + else: + connection.send_error(msg["id"], "invalid_end_time", "Invalid end_time") + return + + elif "rolling_window" in msg: + duration = msg["rolling_window"]["duration"] + now = dt_util.utcnow() + start_time = now - duration + end_time = start_time + duration + + if offset := msg["rolling_window"].get("offset"): + start_time += offset + end_time += offset + + connection.send_message( + await get_instance(hass).async_add_executor_job( + _ws_get_statistic_during_period, + hass, + msg["id"], + start_time, + end_time, + msg.get("statistic_id"), + msg.get("types"), + msg.get("units"), + ) + ) + + def _ws_get_statistics_during_period( hass: HomeAssistant, msg_id: int, start_time: dt, end_time: dt | None, statistic_ids: list[str] | None, - period: Literal["5minute", "day", "hour", "month"], + period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str], ) -> str: """Fetch statistics and convert them to json in the executor.""" @@ -118,7 +264,7 @@ async def ws_handle_get_statistics_during_period( vol.Required("start_time"): str, vol.Optional("end_time"): str, vol.Optional("statistic_ids"): [str], - vol.Required("period"): vol.Any("5minute", "hour", "day", "month"), + vol.Required("period"): vol.Any("5minute", "hour", "day", "week", "month"), vol.Optional("units"): vol.Schema( { vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), @@ -135,7 +281,7 @@ async def ws_handle_get_statistics_during_period( ) @websocket_api.async_response async def ws_get_statistics_during_period( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle statistics websocket command.""" await ws_handle_get_statistics_during_period(hass, connection, msg) @@ -174,7 +320,7 @@ async def ws_handle_list_statistic_ids( ) @websocket_api.async_response async def ws_list_statistic_ids( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Fetch a list of available statistic_id.""" await ws_handle_list_statistic_ids(hass, connection, msg) @@ -187,7 +333,7 @@ async def ws_list_statistic_ids( ) @websocket_api.async_response async def ws_validate_statistics( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Fetch a list of available statistic_id.""" instance = get_instance(hass) @@ -207,7 +353,7 @@ async def ws_validate_statistics( ) @callback def ws_clear_statistics( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Clear statistics for a list of statistic_ids. @@ -226,7 +372,7 @@ def ws_clear_statistics( ) @websocket_api.async_response async def ws_get_statistics_metadata( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Get metadata for a list of statistic_ids.""" instance = get_instance(hass) @@ -246,7 +392,7 @@ async def ws_get_statistics_metadata( ) @callback def ws_update_statistics_metadata( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Update statistics metadata for a statistic_id. @@ -269,7 +415,7 @@ def ws_update_statistics_metadata( ) @callback def ws_change_statistics_unit( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Change the unit_of_measurement for a statistic_id. @@ -296,7 +442,7 @@ def ws_change_statistics_unit( ) @websocket_api.async_response async def ws_adjust_sum_statistics( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Adjust sum statistics. @@ -371,7 +517,7 @@ async def ws_adjust_sum_statistics( ) @callback def ws_import_statistics( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Import statistics.""" metadata = msg["metadata"] @@ -391,7 +537,7 @@ def ws_import_statistics( ) @callback def ws_info( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return status of the recorder.""" instance = get_instance(hass) @@ -417,7 +563,7 @@ def ws_info( @websocket_api.websocket_command({vol.Required("type"): "backup/start"}) @websocket_api.async_response async def ws_backup_start( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Backup start notification.""" @@ -435,7 +581,7 @@ async def ws_backup_start( @websocket_api.websocket_command({vol.Required("type"): "backup/end"}) @websocket_api.async_response async def ws_backup_end( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Backup end notification.""" diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 6943e7f8aa37d865ad16b94ea7e5bf0ff6b831ea..fa1f9cc0b24762bc8338a4bed4597e775237e0f9 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -15,7 +15,7 @@ import rjpl import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES +from homeassistant.const import CONF_NAME, TIME_MINUTES from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -37,8 +37,6 @@ ATTR_REAL_TIME_AT = "real_time_at" ATTR_TRACK = "track" ATTR_NEXT_UP = "next_departures" -ATTRIBUTION = "Data provided by rejseplanen.dk" - CONF_STOP_ID = "stop_id" CONF_ROUTE = "route" CONF_DIRECTION = "direction" @@ -100,6 +98,8 @@ def setup_platform( class RejseplanenTransportSensor(SensorEntity): """Implementation of Rejseplanen transport sensor.""" + _attr_attribution = "Data provided by rejseplanen.dk" + def __init__(self, data, stop_id, route, direction, name): """Initialize the sensor.""" self.data = data @@ -123,14 +123,13 @@ class RejseplanenTransportSensor(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" if not self._times: - return {ATTR_STOP_ID: self._stop_id, ATTR_ATTRIBUTION: ATTRIBUTION} + return {ATTR_STOP_ID: self._stop_id} next_up = [] if len(self._times) > 1: next_up = self._times[1:] attributes = { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_NEXT_UP: next_up, ATTR_STOP_ID: self._stop_id, } diff --git a/homeassistant/components/remote_rpi_gpio/manifest.json b/homeassistant/components/remote_rpi_gpio/manifest.json index f1613b2ba6b822426248db989c85c63c09202470..64a7b0e48a85099c5fe323561dac10ac1f75dc76 100644 --- a/homeassistant/components/remote_rpi_gpio/manifest.json +++ b/homeassistant/components/remote_rpi_gpio/manifest.json @@ -1,6 +1,6 @@ { "domain": "remote_rpi_gpio", - "name": "remote_rpi_gpio", + "name": "Raspberry Pi Remote GPIO", "documentation": "https://www.home-assistant.io/integrations/remote_rpi_gpio", "requirements": ["gpiozero==1.6.2", "pigpio==1.78"], "codeowners": [], diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 33dc8c3dc8db0b2966ce67bccee545b79593e0d7..5b2fc146e928eaffd76d88fc235ed6193d4b7e08 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -6,6 +6,5 @@ "requirements": ["renault-api==0.1.11"], "codeowners": ["@epenet"], "iot_class": "cloud_polling", - "loggers": ["renault_api"], - "supported_brands": { "dacia": "Dacia" } + "loggers": ["renault_api"] } diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index 9af47206e3c8953db0e24f66e75ff683eb0df4fa..2a1e207695c059fff85db26072f07fdef8810f2c 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -24,7 +24,6 @@ class RenaultSelectRequiredKeysMixin: data_key: str icon_lambda: Callable[[RenaultSelectEntity], str] - options: list[str] @dataclass @@ -74,11 +73,6 @@ class RenaultSelectEntity( """Icon handling.""" return self.entity_description.icon_lambda(self) - @property - def options(self) -> list[str]: - """Return a set of selectable options.""" - return self.entity_description.options - async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.vehicle.vehicle.set_charge_mode(option) diff --git a/homeassistant/components/renault/translations/bg.json b/homeassistant/components/renault/translations/bg.json index f074b9653c5713165b9f2d1dc8927e01c44547c7..364d397cb57349383a9f6f52cdc88e918827e558 100644 --- a/homeassistant/components/renault/translations/bg.json +++ b/homeassistant/components/renault/translations/bg.json @@ -18,7 +18,7 @@ "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "Email" + "username": "\u0418\u043c\u0435\u0439\u043b" } } } diff --git a/homeassistant/components/renault/translations/no.json b/homeassistant/components/renault/translations/no.json index 1e53c2718ebed1fd3781e868b26fd1fc5968cb59..3891d56b92863f0dbe68e4cce95fc9fdecc27e2a 100644 --- a/homeassistant/components/renault/translations/no.json +++ b/homeassistant/components/renault/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Kontoen er allerede konfigurert", "kamereon_no_account": "Finner ikke Kamereon-konto", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_credentials": "Ugyldig godkjenning" diff --git a/homeassistant/components/repairs/issue_handler.py b/homeassistant/components/repairs/issue_handler.py index 35a8d9cc49df179712b6d76684c1b080d838da76..9c5e3854773a5a9d231ea589b18f78e5319ea7ae 100644 --- a/homeassistant/components/repairs/issue_handler.py +++ b/homeassistant/components/repairs/issue_handler.py @@ -11,8 +11,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) - -# pylint: disable-next=unused-import from homeassistant.helpers.issue_registry import ( async_delete_issue, async_get as async_get_issue_registry, diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index b4ccca7c8941c848254ae74aa5a5f3672559376a..c5408054318ca8c237dc0309a06fe6ab8e2fe9e5 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -46,7 +46,7 @@ def async_setup(hass: HomeAssistant) -> None: } ) def ws_ignore_issue( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Fix an issue.""" async_ignore_issue(hass, msg["domain"], msg["issue_id"], msg["ignore"]) @@ -61,7 +61,7 @@ def ws_ignore_issue( ) @callback def ws_list_issues( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of issues.""" diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 96ed1ada7ae3d041115803649e1f9b23f38bd500..5549abc214330701f679a62bbf1388be6a6daf3e 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -1,7 +1,9 @@ """The rest component.""" +from __future__ import annotations import asyncio import contextlib +from datetime import timedelta import logging import httpx @@ -34,7 +36,7 @@ from homeassistant.helpers.reload import ( async_integration_yaml_config, async_reload_integration_platforms, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import COORDINATOR, DOMAIN, PLATFORM_IDX, REST, REST_DATA, REST_IDX @@ -76,21 +78,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @callback -def _async_setup_shared_data(hass: HomeAssistant): +def _async_setup_shared_data(hass: HomeAssistant) -> None: """Create shared data for platform config and rest coordinators.""" hass.data[DOMAIN] = {key: [] for key in (REST_DATA, *COORDINATOR_AWARE_PLATFORMS)} -async def _async_process_config(hass, config) -> bool: +async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> bool: """Process rest configuration.""" if DOMAIN not in config: return True refresh_tasks = [] load_tasks = [] - for rest_idx, conf in enumerate(config[DOMAIN]): - scan_interval = conf.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - resource_template = conf.get(CONF_RESOURCE_TEMPLATE) + rest_config: list[ConfigType] = config[DOMAIN] + for rest_idx, conf in enumerate(rest_config): + scan_interval: timedelta = conf.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + resource_template: template.Template | None = conf.get(CONF_RESOURCE_TEMPLATE) rest = create_rest_data_from_config(hass, conf) coordinator = _rest_coordinator(hass, rest, resource_template, scan_interval) refresh_tasks.append(coordinator.async_refresh()) @@ -122,18 +125,25 @@ async def _async_process_config(hass, config) -> bool: return True -async def async_get_config_and_coordinator(hass, platform_domain, discovery_info): +async def async_get_config_and_coordinator( + hass: HomeAssistant, platform_domain: str, discovery_info: DiscoveryInfoType +) -> tuple[ConfigType, DataUpdateCoordinator[None], RestData]: """Get the config and coordinator for the platform from discovery.""" shared_data = hass.data[DOMAIN][REST_DATA][discovery_info[REST_IDX]] - conf = hass.data[DOMAIN][platform_domain][discovery_info[PLATFORM_IDX]] - coordinator = shared_data[COORDINATOR] - rest = shared_data[REST] + conf: ConfigType = hass.data[DOMAIN][platform_domain][discovery_info[PLATFORM_IDX]] + coordinator: DataUpdateCoordinator[None] = shared_data[COORDINATOR] + rest: RestData = shared_data[REST] if rest.data is None: await coordinator.async_request_refresh() return conf, coordinator, rest -def _rest_coordinator(hass, rest, resource_template, update_interval): +def _rest_coordinator( + hass: HomeAssistant, + rest: RestData, + resource_template: template.Template | None, + update_interval: timedelta, +) -> DataUpdateCoordinator[None]: """Wrap a DataUpdateCoordinator around the rest object.""" if resource_template: @@ -154,33 +164,35 @@ def _rest_coordinator(hass, rest, resource_template, update_interval): ) -def create_rest_data_from_config(hass, config): +def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> RestData: """Create RestData from config.""" - resource = config.get(CONF_RESOURCE) - resource_template = config.get(CONF_RESOURCE_TEMPLATE) - method = config.get(CONF_METHOD) - payload = config.get(CONF_PAYLOAD) - verify_ssl = config.get(CONF_VERIFY_SSL) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - headers = config.get(CONF_HEADERS) - params = config.get(CONF_PARAMS) - timeout = config.get(CONF_TIMEOUT) + resource: str | None = config.get(CONF_RESOURCE) + resource_template: template.Template | None = config.get(CONF_RESOURCE_TEMPLATE) + method: str = config[CONF_METHOD] + payload: str | None = config.get(CONF_PAYLOAD) + verify_ssl: bool = config[CONF_VERIFY_SSL] + username: str | None = config.get(CONF_USERNAME) + password: str | None = config.get(CONF_PASSWORD) + headers: dict[str, str] | None = config.get(CONF_HEADERS) + params: dict[str, str] | None = config.get(CONF_PARAMS) + timeout: int = config[CONF_TIMEOUT] if resource_template is not None: resource_template.hass = hass resource = resource_template.async_render(parse_result=False) + if not resource: + raise HomeAssistantError("Resource not set for RestData") + template.attach(hass, headers) template.attach(hass, params) + auth: httpx.DigestAuth | tuple[str, str] | None = None if username and password: if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: auth = httpx.DigestAuth(username, password) else: auth = (username, password) - else: - auth = None return RestData( hass, method, resource, auth, headers, params, payload, verify_ssl, timeout diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 8621963402758c9923b6807a4c1e94222921b2b0..c1990b283368e5cd16eb617833246c279da49578 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -1,8 +1,11 @@ """Support for RESTful API.""" +from __future__ import annotations + import logging import httpx +from homeassistant.core import HomeAssistant from homeassistant.helpers import template from homeassistant.helpers.httpx_client import get_async_client @@ -16,16 +19,16 @@ class RestData: def __init__( self, - hass, - method, - resource, - auth, - headers, - params, - data, - verify_ssl, - timeout=DEFAULT_TIMEOUT, - ): + hass: HomeAssistant, + method: str, + resource: str, + auth: httpx.DigestAuth | tuple[str, str] | None, + headers: dict[str, str] | None, + params: dict[str, str] | None, + data: str | None, + verify_ssl: bool, + timeout: int = DEFAULT_TIMEOUT, + ) -> None: """Initialize the data object.""" self._hass = hass self._method = method @@ -36,16 +39,16 @@ class RestData: self._request_data = data self._timeout = timeout self._verify_ssl = verify_ssl - self._async_client = None - self.data = None - self.last_exception = None - self.headers = None + self._async_client: httpx.AsyncClient | None = None + self.data: str | None = None + self.last_exception: Exception | None = None + self.headers: httpx.Headers | None = None - def set_url(self, url): + def set_url(self, url: str) -> None: """Set url.""" self._resource = url - async def async_update(self, log_errors=True): + async def async_update(self, log_errors: bool = True) -> None: """Get the latest data from REST service with provided method.""" if not self._async_client: self._async_client = get_async_client( @@ -63,7 +66,7 @@ class RestData: headers=rendered_headers, params=rendered_params, auth=self._auth, - data=self._request_data, + content=self._request_data, timeout=self._timeout, follow_redirects=True, ) diff --git a/homeassistant/components/rexel/manifest.json b/homeassistant/components/rexel/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..f3bfcf55c3553e9ad0924e32771929f893e93f49 --- /dev/null +++ b/homeassistant/components/rexel/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "rexel", + "name": "Rexel Energeasy Connect", + "integration_type": "virtual", + "supported_by": "overkiz" +} diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 563b166e0aa3475975501a831bc3c83b72d1f3a0..5f85f344e9bf6e0979f35f36fc57507941a9ad78 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -25,12 +25,12 @@ from homeassistant.const import ( LENGTH_MILLIMETERS, PERCENTAGE, POWER_WATT, - PRECIPITATION_MILLIMETERS_PER_HOUR, PRESSURE_HPA, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, SPEED_METERS_PER_SECOND, TEMP_CELSIUS, UV_INDEX, + UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity, EntityCategory @@ -172,7 +172,8 @@ SENSOR_TYPES = ( key="Rain rate", name="Rain rate", state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), RfxtrxSensorEntityDescription( key="Sound", diff --git a/homeassistant/components/rfxtrx/translations/nb.json b/homeassistant/components/rfxtrx/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..70b79024242a03fdf3e1e251d74db0b874146539 --- /dev/null +++ b/homeassistant/components/rfxtrx/translations/nb.json @@ -0,0 +1,7 @@ +{ + "options": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/diagnostics.py b/homeassistant/components/ridwell/diagnostics.py index 3f29165842f17d403eb6c994852d7b81e56468d7..b48327704093462e2dded816916bff56fbf309ba 100644 --- a/homeassistant/components/ridwell/diagnostics.py +++ b/homeassistant/components/ridwell/diagnostics.py @@ -4,12 +4,24 @@ from __future__ import annotations import dataclasses from typing import Any +from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.core import HomeAssistant from . import RidwellData from .const import DOMAIN +CONF_TITLE = "title" + +TO_REDACT = { + CONF_PASSWORD, + # Config entry title and unique ID may contain sensitive data: + CONF_TITLE, + CONF_UNIQUE_ID, + CONF_USERNAME, +} + async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry @@ -17,6 +29,12 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" data: RidwellData = hass.data[DOMAIN][entry.entry_id] - return { - "data": [dataclasses.asdict(event) for event in data.coordinator.data.values()] - } + return async_redact_data( + { + "entry": entry.as_dict(), + "data": [ + dataclasses.asdict(event) for event in data.coordinator.data.values() + ], + }, + TO_REDACT, + ) diff --git a/homeassistant/components/ridwell/manifest.json b/homeassistant/components/ridwell/manifest.json index 0dbb33b2435dabf128d7f31dcb1a6b368aa1191b..aec0faf5dd3f3b1cc838cd000205312d093f1ba8 100644 --- a/homeassistant/components/ridwell/manifest.json +++ b/homeassistant/components/ridwell/manifest.json @@ -6,5 +6,6 @@ "requirements": ["aioridwell==2022.03.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", - "loggers": ["aioridwell"] + "loggers": ["aioridwell"], + "integration_type": "service" } diff --git a/homeassistant/components/ridwell/translations/nb.json b/homeassistant/components/ridwell/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/ridwell/translations/nb.json +++ b/homeassistant/components/ridwell/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/ridwell/translations/no.json b/homeassistant/components/ridwell/translations/no.json index 0e161b5d614f3f1fe33446485087ca02a04735c1..9ed0aa5a8531cb1958c47beafee27a4b59d91f36 100644 --- a/homeassistant/components/ridwell/translations/no.json +++ b/homeassistant/components/ridwell/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index a64411e610f76662dc3c16871c2c832a59a9514f..25e478d60aec83c6dfb8b2807c6d6a8ca47f2bdf 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "requirements": ["ring_doorbell==0.7.2"], "dependencies": ["ffmpeg"], - "codeowners": ["@balloob"], + "codeowners": [], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/ring/translations/bg.json b/homeassistant/components/ring/translations/bg.json index b0254a1bdf6285a2830210c959fc963bdd15c77e..dfe9fcc384e85134fa3913523425b8091d93abdd 100644 --- a/homeassistant/components/ring/translations/bg.json +++ b/homeassistant/components/ring/translations/bg.json @@ -12,7 +12,7 @@ "data": { "2fa": "\u0414\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u0435\u043d \u043a\u043e\u0434" }, - "title": "\u0414\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + "title": "\u0414\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" }, "user": { "data": { diff --git a/homeassistant/components/ring/translations/nb.json b/homeassistant/components/ring/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/ring/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ripple/sensor.py b/homeassistant/components/ripple/sensor.py index 01f326b2a6377400621d30a0c5b6dbae8b0c7798..bab765c289cf915578e8c5ceb111697a23e07d30 100644 --- a/homeassistant/components/ripple/sensor.py +++ b/homeassistant/components/ripple/sensor.py @@ -7,14 +7,12 @@ from pyripple import get_balance import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_ADDRESS, CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME 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 -ATTRIBUTION = "Data provided by ripple.com" - DEFAULT_NAME = "Ripple Balance" SCAN_INTERVAL = timedelta(minutes=5) @@ -43,6 +41,8 @@ def setup_platform( class RippleSensor(SensorEntity): """Representation of an Ripple.com sensor.""" + _attr_attribution = "Data provided by ripple.com" + def __init__(self, name, address): """Initialize the sensor.""" self._name = name @@ -65,11 +65,6 @@ class RippleSensor(SensorEntity): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - def update(self) -> None: """Get the latest state of the sensor.""" if (balance := get_balance(self.address)) is not None: diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index 179ddd5cad64fdb6c3bd4cf32da1fe78e3554374..0e631cc4a9325d3e419bb6969dc93c0fc220e038 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -28,6 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -50,7 +51,6 @@ class LocalData: """A data class for local data passed to the platforms.""" system: RiscoLocal - zone_updates: dict[int, Callable[[], Any]] = field(default_factory=dict) partition_updates: dict[int, Callable[[], Any]] = field(default_factory=dict) @@ -59,6 +59,11 @@ def is_local(entry: ConfigEntry) -> bool: return entry.data.get(CONF_TYPE) == TYPE_LOCAL +def zone_update_signal(zone_id: int) -> str: + """Return a signal for the dispatch of a zone update.""" + return f"risco_zone_update_{zone_id}" + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Risco from a config entry.""" if is_local(entry): @@ -95,9 +100,7 @@ async def _async_setup_local_entry(hass: HomeAssistant, entry: ConfigEntry) -> b async def _zone(zone_id: int, zone: Zone) -> None: _LOGGER.debug("Risco zone update for %d", zone_id) - callback = local_data.zone_updates.get(zone_id) - if callback: - callback() + async_dispatcher_send(hass, zone_update_signal(zone_id)) entry.async_on_unload(risco.add_zone_handler(_zone)) diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py index 9f98be09f0da2f6817cc9be5c725b9498e930d5f..bc021c2c364c4393480f2d5e25e8c06850535bdb 100644 --- a/homeassistant/components/risco/binary_sensor.py +++ b/homeassistant/components/risco/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Risco alarm zones.""" from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Mapping from typing import Any from pyrisco.common import Zone @@ -13,10 +13,11 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LocalData, RiscoDataUpdateCoordinator, is_local +from . import LocalData, RiscoDataUpdateCoordinator, is_local, zone_update_signal from .const import DATA_COORDINATOR, DOMAIN from .entity import RiscoEntity, binary_sensor_unique_id @@ -24,6 +25,10 @@ SERVICE_BYPASS_ZONE = "bypass_zone" SERVICE_UNBYPASS_ZONE = "unbypass_zone" +def _unique_id_for_local(system_id: str, zone_id: int) -> str: + return f"{system_id}_zone_{zone_id}_local" + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -39,9 +44,11 @@ async def async_setup_entry( if is_local(config_entry): local_data: LocalData = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - RiscoLocalBinarySensor( - local_data.system.id, zone_id, zone, local_data.zone_updates - ) + RiscoLocalBinarySensor(local_data.system.id, zone_id, zone) + for zone_id, zone in local_data.system.zones.items() + ) + async_add_entities( + RiscoLocalAlarmedBinarySensor(local_data.system.id, zone_id, zone) for zone_id, zone in local_data.system.zones.items() ) else: @@ -118,18 +125,10 @@ class RiscoLocalBinarySensor(RiscoBinarySensor): _attr_should_poll = False - def __init__( - self, - system_id: str, - zone_id: int, - zone: Zone, - zone_updates: dict[int, Callable[[], Any]], - ) -> None: + def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: """Init the zone.""" super().__init__(zone_id=zone_id, zone=zone) - self._system_id = system_id - self._zone_updates = zone_updates - self._attr_unique_id = f"{system_id}_zone_{zone_id}_local" + self._attr_unique_id = _unique_id_for_local(system_id, zone_id) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="Risco", @@ -138,7 +137,10 @@ class RiscoLocalBinarySensor(RiscoBinarySensor): async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._zone_updates[self._zone_id] = self.async_write_ha_state + signal = zone_update_signal(self._zone_id) + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, self.async_write_ha_state) + ) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -150,3 +152,41 @@ class RiscoLocalBinarySensor(RiscoBinarySensor): async def _bypass(self, bypass: bool) -> None: await self._zone.bypass(bypass) + + +class RiscoLocalAlarmedBinarySensor(BinarySensorEntity): + """Representation whether a zone in Risco local is currently triggering an alarm.""" + + _attr_should_poll = False + + def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: + """Init the zone.""" + super().__init__() + self._zone_id = zone_id + self._zone = zone + self._attr_has_entity_name = True + self._attr_name = "Alarmed" + device_unique_id = _unique_id_for_local(system_id, zone_id) + self._attr_unique_id = device_unique_id + "_alarmed" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_unique_id)}, + manufacturer="Risco", + name=self._zone.name, + ) + + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + signal = zone_update_signal(self._zone_id) + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, self.async_write_ha_state) + ) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return the state attributes.""" + return {"zone_id": self._zone_id} + + @property + def is_on(self) -> bool | None: + """Return true if sensor is on.""" + return self._zone.alarmed diff --git a/homeassistant/components/risco/translations/he.json b/homeassistant/components/risco/translations/he.json index 08c5ec7d4c025cec93c58bc4f727cb4a1d404276..926afdf8abf988fc2c44b0b6f8d96d392b9f986b 100644 --- a/homeassistant/components/risco/translations/he.json +++ b/homeassistant/components/risco/translations/he.json @@ -9,6 +9,20 @@ "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { + "cloud": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "pin": "\u05e7\u05d5\u05d3 PIN", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "local": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "pin": "\u05e7\u05d5\u05d3 PIN", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + }, "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", diff --git a/homeassistant/components/risco/translations/nb.json b/homeassistant/components/risco/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/risco/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/bg.json b/homeassistant/components/rituals_perfume_genie/translations/bg.json index 05ef3ed780e7500e3b7f96b2dbfdcbc1c0c933a9..7be659cab0ba4ea532ff8f3845bfa455cb495dfd 100644 --- a/homeassistant/components/rituals_perfume_genie/translations/bg.json +++ b/homeassistant/components/rituals_perfume_genie/translations/bg.json @@ -6,6 +6,7 @@ "step": { "user": { "data": { + "email": "\u0418\u043c\u0435\u0439\u043b", "password": "\u041f\u0430\u0440\u043e\u043b\u0430" } } diff --git a/homeassistant/components/rituals_perfume_genie/translations/nb.json b/homeassistant/components/rituals_perfume_genie/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..00f90271cfe282daac9f05919e0f96609fd2796b --- /dev/null +++ b/homeassistant/components/roborock/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "roborock", + "name": "Roborock", + "integration_type": "virtual", + "supported_by": "xiaomi_miio" +} diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 910516b93e8c155b6503dd6ea33b8feea6818c59..1a2d46e625646e93d6c57e8be7e45245a2c8e76b 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -2,6 +2,7 @@ "domain": "roku", "name": "Roku", "documentation": "https://www.home-assistant.io/integrations/roku", + "integration_type": "device", "requirements": ["rokuecp==0.17.0"], "homekit": { "models": ["3820X", "3810X", "4660X", "7820X", "C105X", "C135X"] diff --git a/homeassistant/components/roku/translations/nb.json b/homeassistant/components/roku/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/roku/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index f443f72279f44576ae4b93524f08947f359f4e62..8ec91acf96540cbaf196aeef0980703437e6d77a 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -17,6 +17,7 @@ from homeassistant.const import STATE_IDLE, STATE_PAUSED import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import METRIC_SYSTEM from . import roomba_reported_state from .const import DOMAIN @@ -221,7 +222,7 @@ class IRobotVacuum(IRobotEntity, StateVacuumEntity): if cleaned_area := mission_state.get("sqft", 0): # Imperial # Convert to m2 if the unit_system is set to metric - if self.hass.config.units.is_metric: + if self.hass.config.units is METRIC_SYSTEM: cleaned_area = round(cleaned_area * 0.0929) return (cleaning_time, cleaned_area) diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index af60705f73caf8afdae58bf2715da6ab41c1b1e5..34b31058f5f53556782afdf517168563427dc729 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -17,6 +17,10 @@ { "hostname": "roomba-*", "macaddress": "DCF505*" + }, + { + "hostname": "roomba-*", + "macaddress": "204EF6*" } ], "iot_class": "local_push", diff --git a/homeassistant/components/roomba/translations/ca.json b/homeassistant/components/roomba/translations/ca.json index de0f673e971a6958c1da841795389f3c122d6f00..e480c06d07dc1e456ae317455135930f50ba7cf3 100644 --- a/homeassistant/components/roomba/translations/ca.json +++ b/homeassistant/components/roomba/translations/ca.json @@ -12,14 +12,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Mant\u00e9 premut el bot\u00f3 d'inici a {name} fins que el dispositiu emeti un so (aproximadament dos segons) despr\u00e9s, envia en els seg\u00fcents 30 segons.", + "description": "Assegura't que l'aplicaci\u00f3 d'iRobot no est\u00e0 funcionant en cap dispositiu. Mant\u00e9 premut el bot\u00f3 d'inici a {name} fins que el dispositiu emeti un so (aproximadament dos segons) despr\u00e9s, envia en els seg\u00fcents 30 segons.", "title": "Recupera la contrasenya" }, "link_manual": { "data": { "password": "Contrasenya" }, - "description": "No s'ha pogut obtenir la contrasenya del dispositiu autom\u00e0ticament. Segueix els passos de la seg\u00fcent documentaci\u00f3: {auth_help_url}", + "description": "No s'ha pogut obtenir la contrasenya del dispositiu autom\u00e0ticament. Assegura't que l'aplicaci\u00f3 d'iRobot est\u00e0 tancada de tots els dispositius mentre s'intenti obtenir la contrasenya. Segueix els passos de la documentaci\u00f3 seg\u00fcent: {auth_help_url}", "title": "Introdueix contrasenya" }, "manual": { diff --git a/homeassistant/components/roomba/translations/de.json b/homeassistant/components/roomba/translations/de.json index 1717c07a73578eb7f3d42b6f3305e278f73a6166..89f280b1e944f9edd292cc8e4fce42251e51af56 100644 --- a/homeassistant/components/roomba/translations/de.json +++ b/homeassistant/components/roomba/translations/de.json @@ -12,14 +12,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Halte die Home-Taste von {name} gedr\u00fcckt, bis das Ger\u00e4t einen Ton erzeugt (ca. zwei Sekunden) und sende die Best\u00e4tigung innerhalb von 30 Sekunden ab.", + "description": "Stelle sicher, dass die iRobot-App auf keinem Ger\u00e4t ausgef\u00fchrt wird. Halte die Home-Taste auf {name} gedr\u00fcckt, bis das Ger\u00e4t einen Ton erzeugt (etwa zwei Sekunden) und best\u00e4tige dann innerhalb von 30 Sekunden.", "title": "Passwort abrufen" }, "link_manual": { "data": { "password": "Passwort" }, - "description": "Das Passwort konnte nicht automatisch vom Ger\u00e4t abgerufen werden. Bitte die in der Dokumentation beschriebenen Schritte unter {auth_help_url} befolgen", + "description": "Das Passwort konnte nicht automatisch vom Ger\u00e4t abgerufen werden. Bitte stelle sicher, dass die iRobot-App auf keinem Ger\u00e4t ge\u00f6ffnet ist, w\u00e4hrend du versuchst, das Passwort abzurufen. Bitte befolge die Schritte in der Dokumentation unter: {auth_help_url}", "title": "Passwort eingeben" }, "manual": { diff --git a/homeassistant/components/roomba/translations/en.json b/homeassistant/components/roomba/translations/en.json index 703ccebbb111fa7f948e7f29fd25cb010375f72e..396bca9e8eff4b2a9e57e0ff583ebd8fda41ec26 100644 --- a/homeassistant/components/roomba/translations/en.json +++ b/homeassistant/components/roomba/translations/en.json @@ -12,14 +12,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Press and hold the Home button on {name} until the device generates a sound (about two seconds), then submit within 30 seconds.", + "description": "Make sure that the iRobot app is not running on any device. Press and hold the Home button on {name} until the device generates a sound (about two seconds), then submit within 30 seconds.", "title": "Retrieve Password" }, "link_manual": { "data": { "password": "Password" }, - "description": "The password could not be retrieved from the device automatically. Please follow the steps outlined in the documentation at: {auth_help_url}", + "description": "The password could not be retrieved from the device automatically. Please make sure that the iRobot app is not open on any device while trying to retrieve the password. Please follow the steps outlined in the documentation at: {auth_help_url}", "title": "Enter Password" }, "manual": { diff --git a/homeassistant/components/roomba/translations/es.json b/homeassistant/components/roomba/translations/es.json index 0744bf056208d8aba48d5b4f05920d4035150445..6e7b3a6d2c0f4f115f448a35a68601a55b86b988 100644 --- a/homeassistant/components/roomba/translations/es.json +++ b/homeassistant/components/roomba/translations/es.json @@ -12,14 +12,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Mant\u00e9n pulsado el bot\u00f3n Inicio en {name} hasta que el dispositivo genere un sonido (alrededor de dos segundos), luego haz clic en enviar en los siguientes 30 segundos.", + "description": "Aseg\u00farate de que la aplicaci\u00f3n iRobot no se est\u00e9 ejecutando en ning\u00fan dispositivo. Mant\u00e9n presionado el bot\u00f3n Inicio en {name} hasta que el dispositivo genere un sonido (alrededor de dos segundos), luego pulsa Enviar dentro de los 30 segundos siguientes.", "title": "Recuperar Contrase\u00f1a" }, "link_manual": { "data": { "password": "Contrase\u00f1a" }, - "description": "La contrase\u00f1a no se pudo recuperar del dispositivo autom\u00e1ticamente. Por favor, sigue los pasos descritos en la documentaci\u00f3n en: {auth_help_url}", + "description": "La contrase\u00f1a no se pudo recuperar del dispositivo autom\u00e1ticamente. Por favor, aseg\u00farate de que la aplicaci\u00f3n iRobot no est\u00e9 abierta en ning\u00fan dispositivo mientras intentas recuperar la contrase\u00f1a. Sigue los pasos descritos en la documentaci\u00f3n en: {auth_help_url}", "title": "Introduce la contrase\u00f1a" }, "manual": { diff --git a/homeassistant/components/roomba/translations/hu.json b/homeassistant/components/roomba/translations/hu.json index b4126e202d19f8d7257e5a2cc1818d2140aaf09c..e8597a0cd38fa3fd00bad00df58e38807efb0c40 100644 --- a/homeassistant/components/roomba/translations/hu.json +++ b/homeassistant/components/roomba/translations/hu.json @@ -12,14 +12,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Nyomja meg \u00e9s tartsa lenyomva a {name} Home gombj\u00e1t, am\u00edg az eszk\u00f6z hangot ad (kb. k\u00e9t m\u00e1sodperc), majd engedje el 30 m\u00e1sodpercen bel\u00fcl.", + "description": "Gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy az iRobot alkalmaz\u00e1s nem fut egyik eszk\u00f6z\u00f6n sem. Tartsa lenyomva a {name} Home gombot, am\u00edg a k\u00e9sz\u00fcl\u00e9k hangot nem ad ki (kb. k\u00e9t m\u00e1sodpercig), majd 30 m\u00e1sodpercen bel\u00fcl k\u00fcldje be.", "title": "Jelsz\u00f3 lek\u00e9r\u00e9se" }, "link_manual": { "data": { "password": "Jelsz\u00f3" }, - "description": "A jelsz\u00f3t nem siker\u00fclt automatikusan lek\u00e9rni az eszk\u00f6zr\u0151l. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3ban ismertetett l\u00e9p\u00e9seket: {auth_help_url}", + "description": "A jelsz\u00f3t nem siker\u00fclt automatikusan lek\u00e9rdezni a k\u00e9sz\u00fcl\u00e9kr\u0151l. K\u00e9rj\u00fck, gy\u0151z\u0151dj\u00f6n meg r\u00f3la, hogy az iRobot alkalmaz\u00e1s nincs nyitva egyik eszk\u00f6z\u00f6n sem, mik\u00f6zben megpr\u00f3b\u00e1lja lek\u00e9rdezni a jelsz\u00f3t. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3ban le\u00edrt l\u00e9p\u00e9seket a k\u00f6vetkez\u0151 c\u00edmen: {auth_help_url}", "title": "Jelsz\u00f3 megad\u00e1sa" }, "manual": { diff --git a/homeassistant/components/roomba/translations/id.json b/homeassistant/components/roomba/translations/id.json index 9b8e9ef8bbe95f2822da3288a750ae78817579a4..ff2c3acad2cbcf17ac5bb4972d13ff04fc72ff56 100644 --- a/homeassistant/components/roomba/translations/id.json +++ b/homeassistant/components/roomba/translations/id.json @@ -12,14 +12,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Tekan dan tahan tombol Home pada {name} hingga perangkat mengeluarkan suara (sekitar dua detik), lalu kirim dalam waktu 30 detik.", + "description": "Pastikan aplikasi iRobot tidak berjalan pada semua perangkat. Tekan dan tahan tombol Home pada {name} hingga perangkat mengeluarkan suara (sekitar dua detik), lalu klik KIRIM dalam waktu 30 detik.", "title": "Ambil Kata Sandi" }, "link_manual": { "data": { "password": "Kata Sandi" }, - "description": "Kata sandi tidak dapat diambil dari perangkat secara otomatis. Ikuti langkah-langkah yang diuraikan dalam dokumentasi di: {auth_help_url}", + "description": "Kata sandi tidak dapat diambil dari perangkat secara otomatis. Pastikan aplikasi iRobot tidak terbuka di semua perangkat saat mencoba mengambil kata sandi. Ikuti langkah-langkah yang diuraikan dalam dokumentasi di: {auth_help_url}", "title": "Masukkan Kata Sandi" }, "manual": { diff --git a/homeassistant/components/roomba/translations/it.json b/homeassistant/components/roomba/translations/it.json index b87a7cb8ef61193f58dd29600b683df4e86f9d0c..5882c8544c663d3a3db3f901aa50ca541c15f735 100644 --- a/homeassistant/components/roomba/translations/it.json +++ b/homeassistant/components/roomba/translations/it.json @@ -12,14 +12,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Tieni premuto il pulsante Home su {name} fino a quando il dispositivo non genera un suono (circa due secondi), quindi invialo entro 30 secondi.", + "description": "Assicurati che l'app iRobot non sia in esecuzione su nessun dispositivo. Tieni premuto il pulsante Home su {name} finch\u00e9 il dispositivo non genera un suono (circa due secondi), quindi invia entro 30 secondi.", "title": "Recupera password" }, "link_manual": { "data": { "password": "Password" }, - "description": "La password non pu\u00f2 essere recuperata automaticamente dal dispositivo. Segui le istruzioni indicate sulla documentazione a: {auth_help_url}", + "description": "Non \u00e8 stato possibile recuperare automaticamente la password dal dispositivo. Assicurati che l'app iRobot non sia aperta su nessun dispositivo durante il tentativo di recuperare la password. Segui i passaggi descritti nella documentazione all'indirizzo: {auth_help_url}", "title": "Inserisci la password" }, "manual": { diff --git a/homeassistant/components/roomba/translations/no.json b/homeassistant/components/roomba/translations/no.json index f378bfb127cbe7cb38e0004874a70493cd5b9c22..ccb50b901bb87780e8b7ab4adb003f50320c4339 100644 --- a/homeassistant/components/roomba/translations/no.json +++ b/homeassistant/components/roomba/translations/no.json @@ -12,14 +12,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Trykk og hold nede Hjem-knappen p\u00e5 {name} til enheten genererer en lyd (omtrent to sekunder), og send deretter innen 30 sekunder.", + "description": "Pass p\u00e5 at iRobot-appen ikke kj\u00f8rer p\u00e5 noen enhet. Trykk og hold Hjem-knappen p\u00e5 {name} til enheten genererer en lyd (omtrent to sekunder), og send deretter inn innen 30 sekunder.", "title": "Hent passord" }, "link_manual": { "data": { "password": "Passord" }, - "description": "Passordet kan ikke hentes fra enheten automatisk. F\u00f8lg trinnene som er beskrevet i dokumentasjonen p\u00e5: {auth_help_url}", + "description": "Passordet kunne ikke hentes fra enheten automatisk. S\u00f8rg for at iRobot-appen ikke er \u00e5pen p\u00e5 noen enhet mens du pr\u00f8ver \u00e5 hente passordet. F\u00f8lg trinnene som er beskrevet i dokumentasjonen p\u00e5: {auth_help_url}", "title": "Skriv inn passord" }, "manual": { diff --git a/homeassistant/components/roomba/translations/pl.json b/homeassistant/components/roomba/translations/pl.json index a3440c97ed224d9381aebab753c9a178fc4ddb84..bdfe8417ba7467890be1eb27ecfb872e3f960ba9 100644 --- a/homeassistant/components/roomba/translations/pl.json +++ b/homeassistant/components/roomba/translations/pl.json @@ -12,14 +12,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Naci\u015bnij i przytrzymaj przycisk Home na {name} a\u017c urz\u0105dzenie wygeneruje d\u017awi\u0119k (oko\u0142o dwie sekundy), a nast\u0119pnie zatwierd\u017a w ci\u0105gu 30 sekund.", + "description": "Upewnij si\u0119, \u017ce aplikacja iRobot nie jest uruchomiona na \u017cadnym urz\u0105dzeniu. Naci\u015bnij i przytrzymaj przycisk Home na {name} a\u017c urz\u0105dzenie wygeneruje d\u017awi\u0119k (oko\u0142o dwie sekundy), a nast\u0119pnie zatwierd\u017a w ci\u0105gu 30 sekund.", "title": "Odzyskiwanie has\u0142a" }, "link_manual": { "data": { "password": "Has\u0142o" }, - "description": "Nie mo\u017cna automatycznie pobra\u0107 has\u0142a z urz\u0105dzenia. Post\u0119puj zgodnie z instrukcjami podanymi w dokumentacji pod adresem: {auth_help_url}", + "description": "Nie mo\u017cna automatycznie pobra\u0107 has\u0142a z urz\u0105dzenia. Upewnij si\u0119, \u017ce aplikacja iRobot nie jest otwarta na \u017cadnym urz\u0105dzeniu podczas pr\u00f3by odzyskania has\u0142a. Post\u0119puj zgodnie z instrukcjami podanymi w dokumentacji pod adresem: {auth_help_url}", "title": "Wprowad\u017a has\u0142o" }, "manual": { diff --git a/homeassistant/components/roomba/translations/pt-BR.json b/homeassistant/components/roomba/translations/pt-BR.json index 4db4e885ac798acde5e3deee13fa853d0fd230a2..a9642707d51d1e7953d31122bfbc68283623877a 100644 --- a/homeassistant/components/roomba/translations/pt-BR.json +++ b/homeassistant/components/roomba/translations/pt-BR.json @@ -12,14 +12,14 @@ "flow_title": "{name} ( {host} )", "step": { "link": { - "description": "Pressione e segure o bot\u00e3o Home em {name} at\u00e9 que o dispositivo gere um som (cerca de dois segundos) e envie em 30 segundos.", + "description": "Certifique-se de que o aplicativo iRobot n\u00e3o esteja sendo executado em nenhum dispositivo. Pressione e segure o bot\u00e3o In\u00edcio em {name} at\u00e9 que o dispositivo gere um som (cerca de dois segundos) e envie em 30 segundos.", "title": "Recuperar Senha" }, "link_manual": { "data": { "password": "Senha" }, - "description": "A senha do dispositivo n\u00e3o p\u00f4de ser recuperada automaticamente. Siga as etapas descritas na documenta\u00e7\u00e3o em: {auth_help_url}", + "description": "A senha n\u00e3o p\u00f4de ser recuperada do dispositivo automaticamente. Certifique-se de que o aplicativo iRobot n\u00e3o esteja aberto em nenhum dispositivo ao tentar recuperar a senha. Siga as etapas descritas na documenta\u00e7\u00e3o em: {auth_help_url}", "title": "Digite a senha" }, "manual": { diff --git a/homeassistant/components/roomba/translations/pt.json b/homeassistant/components/roomba/translations/pt.json index 5e40221cec6f260130134552527b7d3e9a212051..fc6cc956c6dcfaf94bd02839cef87eba52475a78 100644 --- a/homeassistant/components/roomba/translations/pt.json +++ b/homeassistant/components/roomba/translations/pt.json @@ -1,20 +1,22 @@ { "config": { "abort": { - "not_irobot_device": "O dispositivo descoberto n\u00e3o \u00e9 um dispositivo iRobot" + "not_irobot_device": "O dispositivo descoberto n\u00e3o \u00e9 um iRobot" }, "error": { "cannot_connect": "Falha na liga\u00e7\u00e3o" }, - "flow_title": "iRobot {name} ({host})", + "flow_title": "{name} ({host})", "step": { "link": { + "description": "Por favor, garanta que a app iRobot Home n\u00e3o est\u00e1 aberta em nenhum dispositivo enquanto tenta obter a palavra-passe. Prima e mantenha o bot\u00e3o Home em {name} at\u00e9 o dispositivo emitir um som (cerca de 2 segundos), e clique em Enviar nos 30 segundos seguintes.", "title": "Recuperar Palavra-passe" }, "link_manual": { "data": { "password": "Palavra-passe" - } + }, + "description": "A palavra-passe n\u00e3o p\u00f4de ser obtida automaticamente a partir do dispositivo. Por favor, garanta que a app iRobot Home n\u00e3o est\u00e1 aberta em nenhum dispositivo enquanto tenta obter a palavra-passe. Siga os passos descritos na documenta\u00e7\u00e3o em: {auth_help_url}." }, "manual": { "data": { diff --git a/homeassistant/components/roomba/translations/ru.json b/homeassistant/components/roomba/translations/ru.json index 643da09744ccdb51899233cf4baa4160b0681dc6..52b68a454668717acb7236a79ceb135939490dc2 100644 --- a/homeassistant/components/roomba/translations/ru.json +++ b/homeassistant/components/roomba/translations/ru.json @@ -12,14 +12,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0438 \u0443\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0439\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 Home \u043d\u0430 {name}, \u043f\u043e\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0438\u0437\u0434\u0430\u0441\u0442 \u0437\u0432\u0443\u043a (\u043e\u043a\u043e\u043b\u043e \u0434\u0432\u0443\u0445 \u0441\u0435\u043a\u0443\u043d\u0434). \u0417\u0430\u0442\u0435\u043c \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0435 30 \u0441\u0435\u043a\u0443\u043d\u0434 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \"\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c\".", + "description": "\u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 iRobot \u043d\u0435 \u0437\u0430\u043f\u0443\u0449\u0435\u043d\u043e \u043d\u0438 \u043d\u0430 \u043e\u0434\u043d\u043e\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0438 \u0443\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0439\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 Home \u043d\u0430 {name}, \u043f\u043e\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0438\u0437\u0434\u0430\u0441\u0442 \u0437\u0432\u0443\u043a (\u043e\u043a\u043e\u043b\u043e \u0434\u0432\u0443\u0445 \u0441\u0435\u043a\u0443\u043d\u0434). \u0417\u0430\u0442\u0435\u043c \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0435 30 \u0441\u0435\u043a\u0443\u043d\u0434 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \"\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c\".", "title": "\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435 \u043f\u0430\u0440\u043e\u043b\u044f" }, "link_manual": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u044c" }, - "description": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043f\u0430\u0440\u043e\u043b\u044c \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430. \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: {auth_help_url}.", + "description": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043f\u0430\u0440\u043e\u043b\u044c \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430. \u041f\u0440\u0438 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0438 \u043f\u0430\u0440\u043e\u043b\u044f \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 iRobot \u043d\u0435 \u0434\u043e\u043b\u0436\u043d\u043e \u0431\u044b\u0442\u044c \u043e\u0442\u043a\u0440\u044b\u0442\u043e \u043d\u0438 \u043d\u0430 \u043e\u0434\u043d\u043e\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435. \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: {auth_help_url}.", "title": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c" }, "manual": { diff --git a/homeassistant/components/roomba/translations/tr.json b/homeassistant/components/roomba/translations/tr.json index ecdaa8b97be0841f797cd908d9a2f64cc8b712f8..4440aeb152bf6ba600d0a0accb39580bf6e1ad0e 100644 --- a/homeassistant/components/roomba/translations/tr.json +++ b/homeassistant/components/roomba/translations/tr.json @@ -12,14 +12,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Cihaz bir ses \u00e7\u0131karana kadar (yakla\u015f\u0131k iki saniye) {name} \u00fczerindeki Ana Sayfa d\u00fc\u011fmesini bas\u0131l\u0131 tutun, ard\u0131ndan 30 saniye i\u00e7inde g\u00f6nderin.", + "description": "iRobot uygulamas\u0131n\u0131n hi\u00e7bir cihazda \u00e7al\u0131\u015fmad\u0131\u011f\u0131ndan emin olun. Cihaz bir ses \u00e7\u0131karana kadar (yakla\u015f\u0131k iki saniye) {name} \u00fczerindeki Ana Sayfa d\u00fc\u011fmesini bas\u0131l\u0131 tutun, ard\u0131ndan 30 saniye i\u00e7inde g\u00f6nderin.", "title": "\u015eifre Al" }, "link_manual": { "data": { "password": "\u015eifre" }, - "description": "Parola ayg\u0131ttan otomatik olarak al\u0131namad\u0131. L\u00fctfen belgelerde belirtilen ad\u0131mlar\u0131 izleyin: {auth_help_url}", + "description": "\u015eifre cihazdan otomatik olarak al\u0131namad\u0131. L\u00fctfen \u015fifreyi almaya \u00e7al\u0131\u015f\u0131rken iRobot uygulamas\u0131n\u0131n hi\u00e7bir cihazda a\u00e7\u0131k olmad\u0131\u011f\u0131ndan emin olun. L\u00fctfen \u015fu adresteki belgelerde belirtilen ad\u0131mlar\u0131 izleyin: {auth_help_url}", "title": "\u015eifre Girin" }, "manual": { diff --git a/homeassistant/components/roomba/translations/zh-Hant.json b/homeassistant/components/roomba/translations/zh-Hant.json index d0c5942262503371da4b9538d26ca3196165472c..c7cf9ae7b2d738ebb714df19fba7717dfcefe04a 100644 --- a/homeassistant/components/roomba/translations/zh-Hant.json +++ b/homeassistant/components/roomba/translations/zh-Hant.json @@ -12,14 +12,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "\u8acb\u6309\u4f4f {name} \u4e0a\u7684 Home \u9375\u76f4\u5230\u88dd\u7f6e\u767c\u51fa\u8072\u97f3\uff08\u7d04\u5169\u79d2\uff09\uff0c\u7136\u5f8c\u65bc 30 \u79d2\u5167\u50b3\u9001\u3002", + "description": "\u8acb\u78ba\u5b9a\u672a\u5728\u5176\u4ed6\u88dd\u7f6e\u958b\u555f iRobot App\u3002\u8acb\u6309\u4f4f {name} \u4e0a\u7684 Home \u9375\u76f4\u5230\u88dd\u7f6e\u767c\u51fa\u8072\u97f3\uff08\u7d04\u5169\u79d2\uff09\uff0c\u7136\u5f8c\u65bc 30 \u79d2\u5167\u50b3\u9001\u3002", "title": "\u91cd\u7f6e\u5bc6\u78bc" }, "link_manual": { "data": { "password": "\u5bc6\u78bc" }, - "description": "\u5bc6\u78bc\u53ef\u81ea\u52d5\u81ea\u88dd\u7f6e\u4e0a\u53d6\u5f97\u3002\u8acb\u53c3\u95b1\u4ee5\u4e0b\u6587\u4ef6\u7684\u6b65\u9a5f\u9032\u884c\u8a2d\u5b9a\uff1a{auth_help_url}", + "description": "\u5bc6\u78bc\u53ef\u81ea\u52d5\u81ea\u88dd\u7f6e\u4e0a\u53d6\u5f97\u3002\u8acb\u78ba\u5b9a\u65bc\u53d6\u5f97\u5bc6\u78bc\u6642\uff0c\u672a\u5728\u5176\u4ed6\u88dd\u7f6e\u958b\u555f iRobot App\u3002\u8acb\u53c3\u95b1\u4ee5\u4e0b\u6587\u4ef6\u7684\u6b65\u9a5f\u9032\u884c\u8a2d\u5b9a\uff1a{auth_help_url}", "title": "\u8f38\u5165\u5bc6\u78bc" }, "manual": { diff --git a/homeassistant/components/roon/translations/nb.json b/homeassistant/components/roon/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/roon/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/__init__.py b/homeassistant/components/rtsp_to_webrtc/__init__.py index f0e013fc02f1419c6586247d818730394c190238..f5f114bce9c673e5bc852a6b1e1faf69f188247c 100644 --- a/homeassistant/components/rtsp_to_webrtc/__init__.py +++ b/homeassistant/components/rtsp_to_webrtc/__init__.py @@ -19,6 +19,7 @@ Other integrations may use this integration with these steps: from __future__ import annotations import logging +from typing import Any import async_timeout from rtsp_to_webrtc.client import get_adaptive_client @@ -109,7 +110,7 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: ) @callback def ws_get_settings( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle the websocket command.""" connection.send_result( diff --git a/homeassistant/components/rtsp_to_webrtc/translations/bg.json b/homeassistant/components/rtsp_to_webrtc/translations/bg.json index 1c6120581b097f1e10457e03b391ca0c2212b8f7..fbf65852d5553e35047b6f93da040e364bb49d38 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/bg.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/bg.json @@ -3,5 +3,14 @@ "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." } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "\u0410\u0434\u0440\u0435\u0441 \u043d\u0430 Stun \u0441\u044a\u0440\u0432\u044a\u0440\u0430 (\u0445\u043e\u0441\u0442:\u043f\u043e\u0440\u0442)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/ca.json b/homeassistant/components/rtsp_to_webrtc/translations/ca.json index f55a493c49d8f8874ee8c979f112ca2a8ce8eff3..683383d83769c28d722228074a115231d7978931 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/ca.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/ca.json @@ -23,5 +23,14 @@ "title": "Configuraci\u00f3 de RTSPtoWebRTC" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Adre\u00e7a del servidor stun (host:port)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/de.json b/homeassistant/components/rtsp_to_webrtc/translations/de.json index f836d8f3b49449e9379d53a8d829acacfebc8a1e..75c7d590aa8eb4c9d6db337535413f61b6da0e98 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/de.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/de.json @@ -23,5 +23,14 @@ "title": "RTSPtoWebRTC konfigurieren" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Adresse des Stun-Servers (host:port)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/el.json b/homeassistant/components/rtsp_to_webrtc/translations/el.json index 0e4c6baa2876126d5c19d7719afb46df59a53429..19c29763deba3d951113e2139cb14ccaf841fe3f 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/el.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/el.json @@ -23,5 +23,14 @@ "title": "\u0394\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 RTSPtoWebRTC" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "\u0394\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 \u03b4\u03b9\u03b1\u03ba\u03bf\u03bc\u03b9\u03c3\u03c4\u03ae \u03b1\u03bd\u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7\u03c2 (host:port)" + } + } + } } } \ 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 ea3e8c4afc11c44bc2c69b128d5e3fc8fdf405a6..31de18c757b87b0c8e448a5a85eb111b588c75ad 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/es.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/es.json @@ -23,5 +23,14 @@ "title": "Configurar RTSPtoWebRTC" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Direcci\u00f3n del servidor Stun (host:puerto)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/et.json b/homeassistant/components/rtsp_to_webrtc/translations/et.json index e440831eb356c16986eb07a5e434aa26ba203ec8..6eeaf1eee6879dec1323d7d54240e05ac9fd87aa 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/et.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/et.json @@ -23,5 +23,14 @@ "title": "RTSPtoWebRTC seadistamine" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Stun serveri aadress (host:port)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/fr.json b/homeassistant/components/rtsp_to_webrtc/translations/fr.json index e51207a6254fbb41f1f625a15092646d4a036da5..e5ea9ef1eb741776f6d78fec36e703c19ecabe08 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/fr.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/fr.json @@ -23,5 +23,14 @@ "title": "Configurer RTSPtoWebRTC" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Adresse du serveur STUN (h\u00f4te:port)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/hu.json b/homeassistant/components/rtsp_to_webrtc/translations/hu.json index 5da10dd88e476664071ba0f532836eaf5c81595c..3eafb9b4f190be9f89aa0304129f189dc37661cd 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/hu.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/hu.json @@ -23,5 +23,14 @@ "title": "RTSPtoWebRTC konfigur\u00e1l\u00e1sa" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Stun szerver c\u00edme (host:port)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/id.json b/homeassistant/components/rtsp_to_webrtc/translations/id.json index 105e4072300a768c50095e9975609f13333d2f99..c9841a7612b204549ab402e2c8a68a2c26f018a8 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/id.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/id.json @@ -23,5 +23,14 @@ "title": "Konfigurasikan RTSPtoWebrTC" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Alamat server stun (host:port)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/it.json b/homeassistant/components/rtsp_to_webrtc/translations/it.json index c91e0bc34e827d894b7a8319668a0f80701e9f2e..319bdaafbd4734a883e772886ee15e11d045dc48 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/it.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/it.json @@ -23,5 +23,14 @@ "title": "Configura RTSPtoWebRTC" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Indirizzo del server Stun (host:porta)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/no.json b/homeassistant/components/rtsp_to_webrtc/translations/no.json index 9f163b2099dbf452369e6f4badedd161f1797700..f5d8e32b04126c570f34b9a790502f7113390918 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/no.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/no.json @@ -23,5 +23,14 @@ "title": "Konfigurer RTSPtoWebRTC" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Sl\u00e5 serveradresse (vert:port)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/pl.json b/homeassistant/components/rtsp_to_webrtc/translations/pl.json index 25f3ffe785f04fe16bf9e023c4152950d61d8862..8d42c0efcc78e81516b8b97c113e4320561c788c 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/pl.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/pl.json @@ -12,7 +12,7 @@ }, "step": { "hassio_confirm": { - "description": "Czy chcesz skonfigurowa\u0107 Home Assistanta, aby \u0142\u0105czy\u0142 si\u0119 z serwerem RTSPtoWebRTC dostarczonym przez dodatek: {addon} ?", + "description": "Czy chcesz skonfigurowa\u0107 Home Assistanta, aby \u0142\u0105czy\u0142 si\u0119 z serwerem RTSPtoWebRTC dostarczonym przez dodatek: {addon}?", "title": "RTSPtoWebRTC poprzez dodatek Home Assistant" }, "user": { @@ -23,5 +23,14 @@ "title": "Konfiguracja RTSPtoWebRTC" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Adres serwera STUN (host:port)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/pt-BR.json b/homeassistant/components/rtsp_to_webrtc/translations/pt-BR.json index 7856079886272c9c7295231130693869b7ba61c1..707203441fe771701423a456591963473dbdd7e9 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/pt-BR.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/pt-BR.json @@ -23,5 +23,14 @@ "title": "Configurar RTSPtoWebRTC" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Endere\u00e7o do servidor de atordoamento (host:porta)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/ru.json b/homeassistant/components/rtsp_to_webrtc/translations/ru.json index c6ba40a0d7317d8806c2204152d4f567e25e8c31..9441ea7d23230c808b27e0eeaff094f6e4ddccf0 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/ru.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/ru.json @@ -23,5 +23,14 @@ "title": "RTSPtoWebRTC" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "\u0410\u0434\u0440\u0435\u0441 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 Stun (\u0445\u043e\u0441\u0442:\u043f\u043e\u0440\u0442)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/sv.json b/homeassistant/components/rtsp_to_webrtc/translations/sv.json index c748d2feb9c6ec7630efa593aa1477e5f9357e32..d6e8400571dd5088c407090fbf47d8e542406cea 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/sv.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/sv.json @@ -23,5 +23,14 @@ "title": "Konfigurera RTSPtoWebRTC" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Stun serveradress (v\u00e4rd:port)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/tr.json b/homeassistant/components/rtsp_to_webrtc/translations/tr.json index 1331c6dd8c45e881686d9a48db30ae61308f0124..dad6038969786f9d3dcf1fdf23a21bfd8f80b6ad 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/tr.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/tr.json @@ -23,5 +23,14 @@ "title": "RTSPtoWebRTC'yi yap\u0131land\u0131r\u0131n" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Stun sunucu adresi (ana bilgisayar:ba\u011flant\u0131 noktas\u0131)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/zh-Hant.json b/homeassistant/components/rtsp_to_webrtc/translations/zh-Hant.json index ace2e23312b11e248502fbccdcddaaea18b0653d..529e85fd978b020eb5ddb4957c473a486a06be93 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/zh-Hant.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/zh-Hant.json @@ -23,5 +23,14 @@ "title": "\u8a2d\u5b9a RTSPtoWebRTC" } } + }, + "options": { + "step": { + "init": { + "data": { + "stun_server": "Stun \u4f3a\u670d\u5668\u4f4d\u5740\uff08\u4e3b\u6a5f\uff1a\u901a\u8a0a\u57e0\uff09" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 8c3e78ae479e0580c582f4689ab942fbc38dc6bb..c9d9df6068e04577de0fe5117ac010ed87729171 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -55,8 +55,7 @@ def add_new_entities(coordinator, async_add_entities, tracked): new_tracked.append(RuckusUnleashedDevice(coordinator, mac, device[API_NAME])) tracked.add(mac) - if new_tracked: - async_add_entities(new_tracked) + async_add_entities(new_tracked) @callback @@ -77,8 +76,7 @@ def restore_entities(registry, coordinator, entry, async_add_entities, tracked): ) tracked.add(entity.unique_id) - if missing: - async_add_entities(missing) + async_add_entities(missing) class RuckusUnleashedDevice(CoordinatorEntity, ScannerEntity): diff --git a/homeassistant/components/ruckus_unleashed/translations/nb.json b/homeassistant/components/ruckus_unleashed/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 8523231e0840df4efb85beb1d3486b150ecbc24d..a7aa842f4854c2ec942c5566a925023722e8cfc0 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.1.0", - "async-upnp-client==0.31.2" + "async-upnp-client==0.32.1" ], "ssdp": [ { diff --git a/homeassistant/components/samsungtv/translations/nb.json b/homeassistant/components/samsungtv/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/samsungtv/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/translations/no.json b/homeassistant/components/samsungtv/translations/no.json index 3b5156081288631d5a9cb51ad3ee53f31fdfb95d..049b3c27198790a55a0ddcd11271cfc9e8de10d8 100644 --- a/homeassistant/components/samsungtv/translations/no.json +++ b/homeassistant/components/samsungtv/translations/no.json @@ -7,7 +7,7 @@ "cannot_connect": "Tilkobling mislyktes", "id_missing": "Denne Samsung-enheten har ikke serienummer.", "not_supported": "Denne Samsung-enheten st\u00f8ttes forel\u00f8pig ikke.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "unknown": "Uventet feil" }, "error": { diff --git a/homeassistant/components/schedule/translations/bg.json b/homeassistant/components/schedule/translations/bg.json index 292b4186ce8449007cf79be604ca0aa4fd53f0e8..2bc24e109806858bf4ad374686c27dda697cdc0d 100644 --- a/homeassistant/components/schedule/translations/bg.json +++ b/homeassistant/components/schedule/translations/bg.json @@ -1,3 +1,9 @@ { + "state": { + "_": { + "off": "\u0418\u0437\u043a\u043b.", + "on": "\u0412\u043a\u043b." + } + }, "title": "\u0413\u0440\u0430\u0444\u0438\u043a" } \ No newline at end of file diff --git a/homeassistant/components/schedule/translations/he.json b/homeassistant/components/schedule/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..20d715bda0ea0e806637e1512e5487eb332cc330 --- /dev/null +++ b/homeassistant/components/schedule/translations/he.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u05db\u05d1\u05d5\u05d9", + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" + } + }, + "title": "\u05dc\u05d5\u05d7 \u05d6\u05de\u05e0\u05d9\u05dd" +} \ No newline at end of file diff --git a/homeassistant/components/scrape/coordinator.py b/homeassistant/components/scrape/coordinator.py new file mode 100644 index 0000000000000000000000000000000000000000..3e81ba798ae304cd568f5c169df89e36026e501f --- /dev/null +++ b/homeassistant/components/scrape/coordinator.py @@ -0,0 +1,36 @@ +"""Coordinator for the scrape component.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from bs4 import BeautifulSoup + +from homeassistant.components.rest.data import RestData +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +class ScrapeCoordinator(DataUpdateCoordinator[BeautifulSoup]): + """Scrape Coordinator.""" + + def __init__( + self, hass: HomeAssistant, rest: RestData, update_interval: timedelta + ) -> None: + """Initialize Scrape coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Scrape Coordinator", + update_interval=update_interval, + ) + self._rest = rest + + async def _async_update_data(self) -> BeautifulSoup: + """Fetch data from Rest.""" + await self._rest.async_update() + if (data := self._rest.data) is None: + raise UpdateFailed("REST data is not available") + return await self.hass.async_add_executor_job(BeautifulSoup, data, "lxml") diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 4f8ea3d1481d36ce130a72f9f8917af70aa3e4f6..b7e5660e3810b7a1b85d88ec721a8c3bb7e942c8 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -4,6 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/scrape", "requirements": ["beautifulsoup4==4.11.1", "lxml==4.9.1"], "after_dependencies": ["rest"], - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@gjohansson-ST", "@epenet"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 88c9b564b2912ad0fda2ed9cb9c5bd7ab5a39261..b13b5d8463b682bf4ea4857bbd20f6df0375b0db 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -5,7 +5,6 @@ from datetime import timedelta import logging from typing import Any -from bs4 import BeautifulSoup import httpx import voluptuous as vol @@ -24,6 +23,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_RESOURCE, + CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, @@ -31,12 +31,15 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -from homeassistant.core import HomeAssistant +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.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import ScrapeCoordinator _LOGGER = logging.getLogger(__name__) @@ -64,6 +67,7 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, @@ -82,7 +86,7 @@ async def async_setup_platform( resource: str = config[CONF_RESOURCE] method: str = "GET" payload: str | None = None - headers: str | None = config.get(CONF_HEADERS) + headers: dict[str, str] | None = config.get(CONF_HEADERS) verify_ssl: bool = config[CONF_VERIFY_SSL] select: str | None = config.get(CONF_SELECT) attr: str | None = config.get(CONF_ATTR) @@ -90,6 +94,7 @@ async def async_setup_platform( unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) device_class: str | None = config.get(CONF_DEVICE_CLASS) state_class: str | None = config.get(CONF_STATE_CLASS) + unique_id: str | None = config.get(CONF_UNIQUE_ID) username: str | None = config.get(CONF_USERNAME) password: str | None = config.get(CONF_PASSWORD) value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) @@ -105,15 +110,17 @@ async def async_setup_platform( auth = (username, password) rest = RestData(hass, method, resource, auth, headers, None, payload, verify_ssl) - await rest.async_update() - if rest.data is None: + coordinator = ScrapeCoordinator(hass, rest, SCAN_INTERVAL) + await coordinator.async_refresh() + if coordinator.data is None: raise PlatformNotReady async_add_entities( [ ScrapeSensor( - rest, + coordinator, + unique_id, name, select, attr, @@ -124,16 +131,16 @@ async def async_setup_platform( state_class, ) ], - True, ) -class ScrapeSensor(SensorEntity): +class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], SensorEntity): """Representation of a web scrape sensor.""" def __init__( self, - rest: RestData, + coordinator: ScrapeCoordinator, + unique_id: str | None, name: str, select: str | None, attr: str | None, @@ -144,22 +151,22 @@ class ScrapeSensor(SensorEntity): state_class: str | None, ) -> None: """Initialize a web scrape sensor.""" - self.rest = rest + super().__init__(coordinator) self._attr_native_value = None self._select = select self._attr = attr self._index = index self._value_template = value_template self._attr_name = name + self._attr_unique_id = unique_id self._attr_native_unit_of_measurement = unit self._attr_device_class = device_class self._attr_state_class = state_class def _extract_value(self) -> Any: """Parse the html extraction in the executor.""" - raw_data = BeautifulSoup(self.rest.data, "lxml") - _LOGGER.debug(raw_data) - + raw_data = self.coordinator.data + _LOGGER.debug("Raw beautiful soup: %s", raw_data) try: if self._attr is not None: value = raw_data.select(self._select)[self._index][self._attr] @@ -170,32 +177,24 @@ class ScrapeSensor(SensorEntity): else: value = tag.text except IndexError: - _LOGGER.warning("Index '%s' not found in %s", self._attr, self.entity_id) + _LOGGER.warning("Index '%s' not found in %s", self._index, self.entity_id) value = None except KeyError: _LOGGER.warning( "Attribute '%s' not found in %s", self._attr, self.entity_id ) value = None - _LOGGER.debug(value) + _LOGGER.debug("Parsed value: %s", value) return value - async def async_update(self) -> None: - """Get the latest data from the source and updates the state.""" - await self.rest.async_update() - await self._async_update_from_rest_data() - async def async_added_to_hass(self) -> None: """Ensure the data from the initial update is reflected in the state.""" - await self._async_update_from_rest_data() + await super().async_added_to_hass() + self._async_update_from_rest_data() - async def _async_update_from_rest_data(self) -> None: + def _async_update_from_rest_data(self) -> None: """Update state from the rest data.""" - if self.rest.data is None: - _LOGGER.error("Unable to retrieve data for %s", self.name) - return - - value = await self.hass.async_add_executor_job(self._extract_value) + value = self._extract_value() if self._value_template is not None: self._attr_native_value = ( @@ -203,3 +202,9 @@ class ScrapeSensor(SensorEntity): ) else: self._attr_native_value = value + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_from_rest_data() + super()._handle_coordinator_update() diff --git a/homeassistant/components/scrape/translations/bg.json b/homeassistant/components/scrape/translations/bg.json index 89c2ffc788045b5827092b3039b2056db6529b98..1599a1918d766b18cd9190356563ca7b76b7529f 100644 --- a/homeassistant/components/scrape/translations/bg.json +++ b/homeassistant/components/scrape/translations/bg.json @@ -7,7 +7,7 @@ "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", + "authentication": "\u0410\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f", "index": "\u0418\u043d\u0434\u0435\u043a\u0441", "name": "\u0418\u043c\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", @@ -23,7 +23,7 @@ "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", + "authentication": "\u0410\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f", "index": "\u0418\u043d\u0434\u0435\u043a\u0441", "name": "\u0418\u043c\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", diff --git a/homeassistant/components/screenaway/manifest.json b/homeassistant/components/screenaway/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..b48e9348f486c283d9f84c1cfef15e3e098236c3 --- /dev/null +++ b/homeassistant/components/screenaway/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "screenaway", + "name": "ScreenAway", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/screenlogic/translations/bg.json b/homeassistant/components/screenlogic/translations/bg.json index b8fccb94a47124667cf90e6f72f3ea2cefde87d8..9531331d8d5c47dafda6ef81687ff77248067d13 100644 --- a/homeassistant/components/screenlogic/translations/bg.json +++ b/homeassistant/components/screenlogic/translations/bg.json @@ -1,9 +1,13 @@ { "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}", "step": { "gateway_entry": { "data": { + "ip_address": "IP \u0430\u0434\u0440\u0435\u0441", "port": "\u041f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 0effdc2754b83062e26577d4aa4020cc23c1ffdf..359486d26875ca01e9720a470f250fddde0037f8 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass import logging from typing import Any, cast @@ -29,7 +30,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import entity_registry as er, extract_domain_configs +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import ToggleEntity @@ -168,17 +169,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # we will create entities before firing EVENT_COMPONENT_LOADED await async_process_integration_platform_for_component(hass, DOMAIN) - # To register scripts as valid domain for Blueprint + # Register script as valid domain for Blueprint async_get_blueprints(hass) - if not await _async_process_config(hass, config, component): - await async_get_blueprints(hass).async_populate() + await _async_process_config(hass, config, component) + + # Add some default blueprints to blueprints/script, does nothing + # if blueprints/script already exists + await async_get_blueprints(hass).async_populate() async def reload_service(service: ServiceCall) -> None: """Call a service to reload scripts.""" - if (conf := await component.async_prepare_reload()) is None: + await async_get_blueprints(hass).async_reset_cache() + if (conf := await component.async_prepare_reload(skip_reset=True)) is None: return - async_get_blueprints(hass).async_reset_cache() await _async_process_config(hass, conf, component) async def turn_on_service(service: ServiceCall) -> None: @@ -228,50 +232,131 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def _async_process_config(hass, config, component) -> bool: - """Process script configuration. +@dataclass +class ScriptEntityConfig: + """Container for prepared script entity configuration.""" + + config_block: ConfigType + key: str + raw_blueprint_inputs: ConfigType | None + raw_config: ConfigType | None + + +async def _prepare_script_config( + hass: HomeAssistant, + config: ConfigType, +) -> list[ScriptEntityConfig]: + """Parse configuration and prepare script entity configuration.""" + script_configs: list[ScriptEntityConfig] = [] + + conf: dict[str, dict[str, Any] | BlueprintInputs] = config[DOMAIN] + + for key, config_block in conf.items(): + raw_blueprint_inputs = None + raw_config = None + + if isinstance(config_block, BlueprintInputs): + blueprint_inputs = config_block + raw_blueprint_inputs = blueprint_inputs.config_with_inputs + + try: + raw_config = blueprint_inputs.async_substitute() + config_block = cast( + dict[str, Any], + await async_validate_config_item(hass, raw_config), + ) + except vol.Invalid as err: + LOGGER.error( + "Blueprint %s generated invalid script with input %s: %s", + blueprint_inputs.blueprint.name, + blueprint_inputs.inputs, + humanize_error(config_block, err), + ) + continue + else: + raw_config = cast(ScriptConfig, config_block).raw_config + + script_configs.append( + ScriptEntityConfig(config_block, key, raw_blueprint_inputs, raw_config) + ) + + return script_configs + + +async def _create_script_entities( + hass: HomeAssistant, script_configs: list[ScriptEntityConfig] +) -> list[ScriptEntity]: + """Create script entities from prepared configuration.""" + entities: list[ScriptEntity] = [] + + for script_config in script_configs: + + entity = ScriptEntity( + hass, + script_config.key, + script_config.config_block, + script_config.raw_config, + script_config.raw_blueprint_inputs, + ) + entities.append(entity) + + return entities + - Return true, if Blueprints were used. - """ +async def _async_process_config(hass, config, component) -> None: + """Process script configuration.""" entities = [] - blueprints_used = False - - for config_key in extract_domain_configs(config, DOMAIN): - conf: dict[str, dict[str, Any] | BlueprintInputs] = config[config_key] - - for key, config_block in conf.items(): - raw_blueprint_inputs = None - raw_config = None - - if isinstance(config_block, BlueprintInputs): - blueprints_used = True - blueprint_inputs = config_block - raw_blueprint_inputs = blueprint_inputs.config_with_inputs - - try: - raw_config = blueprint_inputs.async_substitute() - config_block = cast( - dict[str, Any], - await async_validate_config_item(hass, raw_config), - ) - except vol.Invalid as err: - LOGGER.error( - "Blueprint %s generated invalid script with input %s: %s", - blueprint_inputs.blueprint.name, - blueprint_inputs.inputs, - humanize_error(config_block, err), - ) + + def script_matches_config(script: ScriptEntity, config: ScriptEntityConfig) -> bool: + return script.unique_id == config.key and script.raw_config == config.raw_config + + def find_matches( + scripts: list[ScriptEntity], + script_configs: list[ScriptEntityConfig], + ) -> tuple[set[int], set[int]]: + """Find matches between a list of script entities and a list of configurations. + + A script or configuration is only allowed to match at most once to handle + the case of multiple scripts with identical configuration. + + Returns a tuple of sets of indices: ({script_matches}, {config_matches}) + """ + script_matches: set[int] = set() + config_matches: set[int] = set() + + for script_idx, script in enumerate(scripts): + for config_idx, config in enumerate(script_configs): + if config_idx in config_matches: + # Only allow a script config to match at most once continue - else: - raw_config = cast(ScriptConfig, config_block).raw_config + if script_matches_config(script, config): + script_matches.add(script_idx) + config_matches.add(config_idx) + # Only allow a script to match at most once + break - entities.append( - ScriptEntity(hass, key, config_block, raw_config, raw_blueprint_inputs) - ) + return script_matches, config_matches - await component.async_add_entities(entities) + script_configs = await _prepare_script_config(hass, config) + scripts: list[ScriptEntity] = list(component.entities) - return blueprints_used + # Find scripts and configurations which have matches + script_matches, config_matches = find_matches(scripts, script_configs) + + # Remove scripts which have changed config or no longer exist + tasks = [ + script.async_remove() + for idx, script in enumerate(scripts) + if idx not in script_matches + ] + await asyncio.gather(*tasks) + + # Create scripts which have changed config or have been added + updated_script_configs = [ + config for idx, config in enumerate(script_configs) if idx not in config_matches + ] + entities = await _create_script_entities(hass, updated_script_configs) + await component.async_add_entities(entities) class ScriptEntity(ToggleEntity, RestoreEntity): diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index e7df1de860e65c472c46117c483b369185be6dba..70702f351f6fc98925c1337fb68b69570b029939 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections import defaultdict, deque import logging +from typing import Any import voluptuous as vol @@ -44,7 +45,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: } ) @callback -def websocket_search_related(hass, connection, msg): +def websocket_search_related( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle search.""" searcher = Searcher( hass, diff --git a/homeassistant/components/season/manifest.json b/homeassistant/components/season/manifest.json index 7b6feeca8a40c11d1fd2327cc21477a15b961d9a..a921d395310c3f5bb45b879f2a9dee4300c712a6 100644 --- a/homeassistant/components/season/manifest.json +++ b/homeassistant/components/season/manifest.json @@ -7,5 +7,6 @@ "quality_scale": "internal", "iot_class": "local_polling", "loggers": ["ephem"], + "integration_type": "service", "config_flow": true } diff --git a/homeassistant/components/season/translations/ca.json b/homeassistant/components/season/translations/ca.json index b12e0e3019c69275412e93db6b930eb6ccc0aab4..a2c47ff2fc3ff96405d73d7481a73825b4615ad0 100644 --- a/homeassistant/components/season/translations/ca.json +++ b/homeassistant/components/season/translations/ca.json @@ -10,5 +10,11 @@ } } } + }, + "issues": { + "removed_yaml": { + "description": "La configuraci\u00f3 d'Estaci\u00f3 de l'any mitjan\u00e7ant YAML s'ha eliminat.\n\nHome Assistant ja no utilitza la configuraci\u00f3 YAML existent.\n\nElimina la configuraci\u00f3 YAML corresponent del fitxer configuration.yaml i reinicia Home Assistant per solucionar aquest problema.", + "title": "La configuraci\u00f3 YAML d'Estaci\u00f3 de l'any s'ha eliminat" + } } } \ No newline at end of file diff --git a/homeassistant/components/season/translations/el.json b/homeassistant/components/season/translations/el.json index 52bf5ca61266a3ca8fd7a9d4aa353c2f992293ce..2f3e0ba48bcfbde15a7407efc869eceaadbe1858 100644 --- a/homeassistant/components/season/translations/el.json +++ b/homeassistant/components/season/translations/el.json @@ -10,5 +10,11 @@ } } } + }, + "issues": { + "removed_yaml": { + "description": "\u0397 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03c4\u03b7\u03c2 Season \u03bc\u03b5 \u03c7\u03c1\u03ae\u03c3\u03b7 YAML \u03ad\u03c7\u03b5\u03b9 \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03b7\u03b8\u03b5\u03af.\n\n\u0397 \u03c5\u03c0\u03ac\u03c1\u03c7\u03bf\u03c5\u03c3\u03b1 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 YAML \u03b4\u03b5\u03bd \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03bf Home Assistant.\n\n\u0391\u03c6\u03b1\u03b9\u03c1\u03ad\u03c3\u03c4\u03b5 \u03c4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 YAML \u03b1\u03c0\u03cc \u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf configuration.yaml \u03ba\u03b1\u03b9 \u03b5\u03c0\u03b1\u03bd\u03b5\u03ba\u03ba\u03b9\u03bd\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf Home Assistant \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03bf\u03c1\u03b8\u03ce\u03c3\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u03c0\u03c1\u03cc\u03b2\u03bb\u03b7\u03bc\u03b1.", + "title": "\u0397 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 Season YAML \u03ad\u03c7\u03b5\u03b9 \u03b1\u03c6\u03b1\u03b9\u03c1\u03b5\u03b8\u03b5\u03af" + } } } \ No newline at end of file diff --git a/homeassistant/components/season/translations/et.json b/homeassistant/components/season/translations/et.json index 6c11c8136e17632f46cbdd4a760ec9ebd25c8919..60abe6adf65b7f33f5347d744d5501fc4e5d70aa 100644 --- a/homeassistant/components/season/translations/et.json +++ b/homeassistant/components/season/translations/et.json @@ -10,5 +10,11 @@ } } } + }, + "issues": { + "removed_yaml": { + "description": "Season seadistamine YAML-i abil on eemaldatud. \n\n Koduassistent ei kasuta teie olemasolevat YAML-i konfiguratsiooni. \n\n Selle probleemi lahendamiseks eemaldage YAML-i konfiguratsioon failist configuration.yaml ja taask\u00e4ivitage Home Assistant.", + "title": "Sidumise Season YAML-i konfiguratsioon on eemaldatud" + } } } \ No newline at end of file diff --git a/homeassistant/components/season/translations/hu.json b/homeassistant/components/season/translations/hu.json index 11bbd17ad6c28b651b132efb0977161cc164971d..32a3e7c77b6f45fd749a5e4c74574b96982624e1 100644 --- a/homeassistant/components/season/translations/hu.json +++ b/homeassistant/components/season/translations/hu.json @@ -10,5 +10,11 @@ } } } + }, + "issues": { + "removed_yaml": { + "description": "A Season konfigur\u00e1l\u00e1sa YAML haszn\u00e1lat\u00e1val elt\u00e1vol\u00edt\u00e1sra ker\u00fclt.\n\nA megl\u00e9v\u0151 YAML konfigur\u00e1ci\u00f3t a Home Assistant nem haszn\u00e1lja.\n\nA probl\u00e9ma megold\u00e1s\u00e1hoz t\u00e1vol\u00edtsa el a YAML konfigur\u00e1ci\u00f3t a configuration.yaml f\u00e1jlb\u00f3l, \u00e9s ind\u00edtsa \u00fajra a Home Assistantot.", + "title": "A Season YAML konfigur\u00e1ci\u00f3 elt\u00e1vol\u00edt\u00e1sra ker\u00fclt" + } } } \ No newline at end of file diff --git a/homeassistant/components/season/translations/id.json b/homeassistant/components/season/translations/id.json index ef7de1c9d3a1f539d0b64492f92bd068aadcabc3..0b557ccaabb528602b782ce413b4f7a93fc8519e 100644 --- a/homeassistant/components/season/translations/id.json +++ b/homeassistant/components/season/translations/id.json @@ -10,5 +10,11 @@ } } } + }, + "issues": { + "removed_yaml": { + "description": "Proses konfigurasi Integrasi Musim lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Musim telah dihapus" + } } } \ No newline at end of file diff --git a/homeassistant/components/season/translations/it.json b/homeassistant/components/season/translations/it.json index f77a74107051e67394b8a96b7eac91fb4b597a01..8771365d3c5d2d391893bb68dd72623bd91ed7d5 100644 --- a/homeassistant/components/season/translations/it.json +++ b/homeassistant/components/season/translations/it.json @@ -10,5 +10,11 @@ } } } + }, + "issues": { + "removed_yaml": { + "description": "La configurazione di Season tramite YAML \u00e8 stata rimossa. \n\nLa tua configurazione YAML esistente non viene utilizzata da Home Assistant. \n\nRimuovere la configurazione YAML dal file configuration.yaml e riavviare Home Assistant per risolvere questo problema.", + "title": "La configurazione YAML di Season \u00e8 stata rimossa" + } } } \ No newline at end of file diff --git a/homeassistant/components/season/translations/no.json b/homeassistant/components/season/translations/no.json index 2c177da822704af31e8962f485daca240f848922..1b1c0f332e58a7ba22c28732e8c43f4fc70977d8 100644 --- a/homeassistant/components/season/translations/no.json +++ b/homeassistant/components/season/translations/no.json @@ -10,5 +10,11 @@ } } } + }, + "issues": { + "removed_yaml": { + "description": "Konfigurering av sesong med YAML er fjernet. \n\n Din eksisterende YAML-konfigurasjon brukes ikke av Home Assistant. \n\n Fjern YAML-konfigurasjonen fra configuration.yaml-filen og start Home Assistant p\u00e5 nytt for \u00e5 fikse dette problemet.", + "title": "Season YAML-konfigurasjonen er fjernet" + } } } \ No newline at end of file diff --git a/homeassistant/components/season/translations/pl.json b/homeassistant/components/season/translations/pl.json index bef7f92841d8276786ed08cf8e08032a1025197d..21342aeb8b801685d89b231d535bce983cea7b25 100644 --- a/homeassistant/components/season/translations/pl.json +++ b/homeassistant/components/season/translations/pl.json @@ -10,5 +10,11 @@ } } } + }, + "issues": { + "removed_yaml": { + "description": "Konfiguracja Sezon\u00f3w za pomoc\u0105 YAML zosta\u0142a usuni\u0119ta. \n\nTwoja istniej\u0105ca konfiguracja YAML nie jest u\u017cywana przez Home Assistant. \n\nUsu\u0144 konfiguracj\u0119 YAML z pliku configuration.yaml i uruchom ponownie Home Assistant, aby rozwi\u0105za\u0107 ten problem.", + "title": "Konfiguracja YAML dla Sezon\u00f3w zosta\u0142a usuni\u0119ta" + } } } \ No newline at end of file diff --git a/homeassistant/components/season/translations/pt-BR.json b/homeassistant/components/season/translations/pt-BR.json index aa4f760180804ab0981e699dbc5c656fe28cc0fe..bc61719f1b157518dcfa8a2bc0cff2e86eccf0a8 100644 --- a/homeassistant/components/season/translations/pt-BR.json +++ b/homeassistant/components/season/translations/pt-BR.json @@ -10,5 +10,11 @@ } } } + }, + "issues": { + "removed_yaml": { + "description": "A configura\u00e7\u00e3o da Season usando YAML foi removida. \n\n Sua configura\u00e7\u00e3o YAML existente n\u00e3o \u00e9 usada pelo Home Assistant. \n\n Remova a configura\u00e7\u00e3o YAML do arquivo configuration.yaml e reinicie o Home Assistant para corrigir esse problema.", + "title": "A configura\u00e7\u00e3o YAML da Season foi removida" + } } } \ No newline at end of file diff --git a/homeassistant/components/season/translations/sv.json b/homeassistant/components/season/translations/sv.json index 649789e560e6889459f1b935ab878b1fc93eff8b..5a8e28e1e072bfb2e40725cadd012f79a401821a 100644 --- a/homeassistant/components/season/translations/sv.json +++ b/homeassistant/components/season/translations/sv.json @@ -10,5 +10,11 @@ } } } + }, + "issues": { + "removed_yaml": { + "description": "Konfigurering av s\u00e4song med YAML har tagits bort. \n\n Din befintliga YAML-konfiguration anv\u00e4nds inte av Home Assistant. \n\n Ta bort YAML-konfigurationen fr\u00e5n filen configuration.yaml och starta om Home Assistant f\u00f6r att \u00e5tg\u00e4rda problemet.", + "title": "S\u00e4song YAML-konfigurationen har tagits bort" + } } } \ No newline at end of file diff --git a/homeassistant/components/season/translations/tr.json b/homeassistant/components/season/translations/tr.json index a625042d5ddb07b2106c5b24597f4fd2bbc9970a..d214692147a217d9a7e9baa42846a593aaa1390e 100644 --- a/homeassistant/components/season/translations/tr.json +++ b/homeassistant/components/season/translations/tr.json @@ -10,5 +10,11 @@ } } } + }, + "issues": { + "removed_yaml": { + "description": "Season'\u0131 YAML kullanarak yap\u0131land\u0131rma kald\u0131r\u0131ld\u0131. \n\n Mevcut YAML yap\u0131land\u0131rman\u0131z Home Assistant taraf\u0131ndan kullan\u0131lm\u0131yor. \n\n YAML yap\u0131land\u0131rmas\u0131n\u0131 configuration.yaml dosyan\u0131zdan kald\u0131r\u0131n ve bu sorunu gidermek i\u00e7in Home Assistant'\u0131 yeniden ba\u015flat\u0131n.", + "title": "Sezon YAML yap\u0131land\u0131rmas\u0131 kald\u0131r\u0131ld\u0131" + } } } \ No newline at end of file diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 56ac28ae39e2a2c42f923c8d2b415c6978ed301c..20cbb86e3aedde00c003692e1e834085a206ac96 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -72,6 +72,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class SelectEntityDescription(EntityDescription): """A class that describes select entities.""" + options: list[str] | None = None + class SelectEntity(Entity): """Representation of a Select entity.""" @@ -99,7 +101,14 @@ class SelectEntity(Entity): @property def options(self) -> list[str]: """Return a set of selectable options.""" - return self._attr_options + if hasattr(self, "_attr_options"): + return self._attr_options + if ( + hasattr(self, "entity_description") + and self.entity_description.options is not None + ): + return self.entity_description.options + raise AttributeError() @property def current_option(self) -> str | None: diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 0d4fbcf1de2be901b61b0e8157dfadab9af09220..2aee20be5aecf2ad9ef330384c9bef60ccc564d3 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -73,6 +72,7 @@ def sense_to_mdi(sense_icon): class SenseDevice(BinarySensorEntity): """Implementation of a Sense energy device binary sensor.""" + _attr_attribution = ATTRIBUTION _attr_should_poll = False def __init__(self, sense_devices_data, device, sense_monitor_id): @@ -111,11 +111,6 @@ class SenseDevice(BinarySensorEntity): """Return the old not so unique id of the binary sensor.""" return self._id - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - @property def icon(self): """Return the icon of the binary sensor.""" diff --git a/homeassistant/components/sense/translations/bg.json b/homeassistant/components/sense/translations/bg.json index f81ad124c5119570b439f12d4310ad454e387a1b..91bf013c04736f25501f1b14383c5619745da8ef 100644 --- a/homeassistant/components/sense/translations/bg.json +++ b/homeassistant/components/sense/translations/bg.json @@ -14,6 +14,11 @@ }, "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" }, + "user": { + "data": { + "email": "\u0418\u043c\u0435\u0439\u043b" + } + }, "validation": { "data": { "code": "\u041a\u043e\u0434 \u0437\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430" diff --git a/homeassistant/components/sense/translations/nb.json b/homeassistant/components/sense/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/sense/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sense/translations/no.json b/homeassistant/components/sense/translations/no.json index 004580b51926eba7a611a3cc57bfb98a0b75fa58..2fdf39a0d89c91e4234cefda446522ed674acc68 100644 --- a/homeassistant/components/sense/translations/no.json +++ b/homeassistant/components/sense/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/senseme/select.py b/homeassistant/components/senseme/select.py index 1fedc7f75d43526444d313ce16090eb8ac586a79..251e6c385d8890bdb39755cb69d8b780817b8021 100644 --- a/homeassistant/components/senseme/select.py +++ b/homeassistant/components/senseme/select.py @@ -49,6 +49,7 @@ FAN_SELECTS = [ name="Smart Mode", value_fn=lambda device: SMART_MODE_TO_HASS[device.fan_smartmode], set_fn=_set_smart_mode, + options=list(SMART_MODE_TO_HASS.values()), ), ] @@ -70,7 +71,6 @@ class HASensemeSelect(SensemeEntity, SelectEntity): """SenseME select component.""" entity_description: SenseMESelectEntityDescription - _attr_options = list(SMART_MODE_TO_HASS.values()) def __init__( self, device: SensemeFan, description: SenseMESelectEntityDescription diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 9c3c5c111f5bbe64b64e2e91ad042dec5430e2cc..70d4a12406a43e998007a3398ccda1d8504bc0ce 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -7,12 +7,15 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_SWING_MODE, ClimateEntity, ClimateEntityFeature, HVACMode, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_MODE, ATTR_STATE, ATTR_TEMPERATURE, PRECISION_TENTHS, @@ -34,12 +37,22 @@ SERVICE_ENABLE_TIMER = "enable_timer" ATTR_MINUTES = "minutes" SERVICE_ENABLE_PURE_BOOST = "enable_pure_boost" SERVICE_DISABLE_PURE_BOOST = "disable_pure_boost" +SERVICE_FULL_STATE = "full_state" +SERVICE_ENABLE_CLIMATE_REACT = "enable_climate_react" +ATTR_HIGH_TEMPERATURE_THRESHOLD = "high_temperature_threshold" +ATTR_HIGH_TEMPERATURE_STATE = "high_temperature_state" +ATTR_LOW_TEMPERATURE_THRESHOLD = "low_temperature_threshold" +ATTR_LOW_TEMPERATURE_STATE = "low_temperature_state" +ATTR_SMART_TYPE = "smart_type" ATTR_AC_INTEGRATION = "ac_integration" ATTR_GEO_INTEGRATION = "geo_integration" ATTR_INDOOR_INTEGRATION = "indoor_integration" ATTR_OUTDOOR_INTEGRATION = "outdoor_integration" ATTR_SENSITIVITY = "sensitivity" +ATTR_TARGET_TEMPERATURE = "target_temperature" +ATTR_HORIZONTAL_SWING_MODE = "horizontal_swing_mode" +ATTR_LIGHT = "light" BOOST_INCLUSIVE = "boost_inclusive" PARALLEL_UPDATES = 0 @@ -118,6 +131,34 @@ async def async_setup_entry( }, "async_enable_pure_boost", ) + platform.async_register_entity_service( + SERVICE_FULL_STATE, + { + vol.Required(ATTR_MODE): vol.In( + ["cool", "heat", "fan", "auto", "dry", "off"] + ), + vol.Optional(ATTR_TARGET_TEMPERATURE): int, + vol.Optional(ATTR_FAN_MODE): str, + vol.Optional(ATTR_SWING_MODE): str, + vol.Optional(ATTR_HORIZONTAL_SWING_MODE): str, + vol.Optional(ATTR_LIGHT): vol.In(["on", "off"]), + }, + "async_full_ac_state", + ) + + platform.async_register_entity_service( + SERVICE_ENABLE_CLIMATE_REACT, + { + vol.Required(ATTR_HIGH_TEMPERATURE_THRESHOLD): float, + vol.Required(ATTR_HIGH_TEMPERATURE_STATE): dict, + vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): float, + vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict, + vol.Required(ATTR_SMART_TYPE): vol.In( + ["temperature", "feelsLike", "humidity"] + ), + }, + "async_enable_climate_react", + ) class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @@ -335,6 +376,37 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): assumed_state=True, ) + async def async_full_ac_state( + self, + mode: str, + target_temperature: int | None = None, + fan_mode: str | None = None, + swing_mode: str | None = None, + horizontal_swing_mode: str | None = None, + light: str | None = None, + ) -> None: + """Set full AC state.""" + new_ac_state = self.device_data.ac_states + new_ac_state.pop("timestamp") + new_ac_state["on"] = False + if mode != "off": + new_ac_state["on"] = True + new_ac_state["mode"] = mode + if target_temperature: + new_ac_state["targetTemperature"] = target_temperature + if fan_mode: + new_ac_state["fanLevel"] = fan_mode + if swing_mode: + new_ac_state["swing"] = swing_mode + if horizontal_swing_mode: + new_ac_state["horizontalSwing"] = horizontal_swing_mode + if light: + new_ac_state["light"] = light + + await self.api_call_custom_service_full_ac_state( + key="hvac_mode", value=mode, data=new_ac_state + ) + async def async_enable_timer(self, minutes: int) -> None: """Enable the timer.""" new_state = bool(self.device_data.ac_states["on"] is False) @@ -378,6 +450,42 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): data=params, ) + async def async_enable_climate_react( + self, + high_temperature_threshold: float, + high_temperature_state: dict[str, Any], + low_temperature_threshold: float, + low_temperature_state: dict[str, Any], + smart_type: str, + ) -> None: + """Enable Climate React Configuration.""" + high_temp = high_temperature_threshold + low_temp = low_temperature_threshold + + if high_temperature_state.get("temperatureUnit") == "F": + high_temp = TemperatureConverter.convert( + high_temperature_threshold, TEMP_FAHRENHEIT, TEMP_CELSIUS + ) + low_temp = TemperatureConverter.convert( + low_temperature_threshold, TEMP_FAHRENHEIT, TEMP_CELSIUS + ) + + params: dict[str, str | bool | float | dict] = { + "enabled": True, + "deviceUid": self._device_id, + "highTemperatureState": high_temperature_state, + "highTemperatureThreshold": high_temp, + "lowTemperatureState": low_temperature_state, + "lowTemperatureThreshold": low_temp, + "type": smart_type, + } + + await self.api_call_custom_service_climate_react( + key="smart_on", + value=True, + data=params, + ) + @async_handle_api_call async def async_send_api_call( self, @@ -404,7 +512,6 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): data: dict, ) -> bool: """Make service call to api.""" - result = {} result = await self._client.async_set_timer(self._device_id, data) return bool(result.get("status") == "success") @@ -416,6 +523,27 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): data: dict, ) -> bool: """Make service call to api.""" - result = {} result = await self._client.async_set_pureboost(self._device_id, data) return bool(result.get("status") == "success") + + @async_handle_api_call + async def api_call_custom_service_climate_react( + self, + key: str, + value: Any, + data: dict, + ) -> bool: + """Make service call to api.""" + result = await self._client.async_set_climate_react(self._device_id, data) + return bool(result.get("status") == "success") + + @async_handle_api_call + async def api_call_custom_service_full_ac_state( + self, + key: str, + value: Any, + data: dict, + ) -> bool: + """Make service call to api.""" + result = await self._client.async_set_ac_states(self._device_id, data) + return bool(result.get("result", {}).get("status") == "Success") diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 3fa764a8757b769e35cf590bd754b8df5903106f..22981ada51f55d5b4ba093b4d62f119f42b25a30 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -23,7 +23,6 @@ from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, TEMP_CELSIUS, - TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory @@ -155,6 +154,32 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( extra_fn=None, entity_registry_enabled_default=False, ), + SensiboDeviceSensorEntityDescription( + key="climate_react_low", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + name="Climate React low temperature threshold", + value_fn=lambda data: data.smart_low_temp_threshold, + extra_fn=lambda data: data.smart_low_state, + entity_registry_enabled_default=False, + ), + SensiboDeviceSensorEntityDescription( + key="climate_react_high", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + name="Climate React high temperature threshold", + value_fn=lambda data: data.smart_high_temp_threshold, + extra_fn=lambda data: data.smart_high_state, + entity_registry_enabled_default=False, + ), + SensiboDeviceSensorEntityDescription( + key="climate_react_type", + device_class="sensibo__smart_type", + name="Climate React type", + value_fn=lambda data: data.smart_type, + extra_fn=None, + entity_registry_enabled_default=False, + ), FILTER_LAST_RESET_DESCRIPTION, ) @@ -237,9 +262,7 @@ class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity): def native_unit_of_measurement(self) -> str | None: """Add native unit of measurement.""" if self.entity_description.device_class == SensorDeviceClass.TEMPERATURE: - return ( - TEMP_CELSIUS if self.device_data.temp_unit == "C" else TEMP_FAHRENHEIT - ) + return TEMP_CELSIUS return self.entity_description.native_unit_of_measurement @property @@ -273,15 +296,16 @@ class SensiboDeviceSensor(SensiboDeviceBaseEntity, SensorEntity): def native_unit_of_measurement(self) -> str | None: """Add native unit of measurement.""" if self.entity_description.device_class == SensorDeviceClass.TEMPERATURE: - return ( - TEMP_CELSIUS if self.device_data.temp_unit == "C" else TEMP_FAHRENHEIT - ) + return TEMP_CELSIUS return self.entity_description.native_unit_of_measurement @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" - return self.entity_description.value_fn(self.device_data) + state = self.entity_description.value_fn(self.device_data) + if isinstance(state, str): + return state.lower() + return state @property def extra_state_attributes(self) -> Mapping[str, Any] | None: diff --git a/homeassistant/components/sensibo/services.yaml b/homeassistant/components/sensibo/services.yaml index 9ce13b70eaab47c39f65cae94ecc7dde633d5b2f..f9f9365eb8e762ca139c2877be0efc0c50dbf2c3 100644 --- a/homeassistant/components/sensibo/services.yaml +++ b/homeassistant/components/sensibo/services.yaml @@ -1,6 +1,6 @@ assume_state: name: Assume state - description: Set Sensibo device to external state. + description: Set Sensibo device to external state target: entity: integration: sensibo @@ -8,7 +8,7 @@ assume_state: fields: state: name: State - description: State to set. + description: State to set required: true example: "on" selector: @@ -18,7 +18,7 @@ assume_state: - "off" enable_timer: name: Enable Timer - description: Enable the timer with custom time. + description: Enable the timer with custom time target: entity: integration: sensibo @@ -36,7 +36,7 @@ enable_timer: mode: box enable_pure_boost: name: Enable Pure Boost - description: Enable and configure Pure Boost settings. + description: Enable and configure Pure Boost settings target: entity: integration: sensibo @@ -44,35 +44,35 @@ enable_pure_boost: fields: ac_integration: name: AC Integration - description: Integrate with Air Conditioner. + description: Integrate with Air Conditioner required: true example: true selector: boolean: geo_integration: name: Geo Integration - description: Integrate with Presence. + description: Integrate with Presence required: true example: true selector: boolean: indoor_integration: name: Indoor Air Quality - description: Integrate with checking 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. + description: Integrate with checking outdoor air quality required: true example: true selector: boolean: sensitivity: name: Sensitivity - description: Set the sensitivity for Pure Boost. + description: Set the sensitivity for Pure Boost required: true example: "Normal" selector: @@ -80,3 +80,122 @@ enable_pure_boost: options: - "Normal" - "Sensitive" +full_state: + name: Set full state + description: Set full state for Sensibo device + target: + entity: + integration: sensibo + domain: climate + fields: + mode: + name: HVAC mode + description: HVAC mode to set + required: true + example: "heat" + selector: + select: + options: + - "cool" + - "heat" + - "fan" + - "auto" + - "dry" + - "off" + target_temperature: + name: Target Temperature + description: Optionally set target temperature + required: false + example: 23 + selector: + number: + min: 0 + step: 1 + mode: box + fan_mode: + name: Fan mode + description: Optionally set fan mode + required: false + example: "low" + selector: + text: + type: text + swing_mode: + name: swing mode + description: Optionally set swing mode + required: false + example: "fixedBottom" + selector: + text: + type: text + horizontal_swing_mode: + name: Horizontal swing mode + description: Optionally set horizontal swing mode + required: false + example: "fixedLeft" + selector: + text: + type: text + light: + name: Light + description: Set light on or off + required: false + example: "on" + selector: + select: + options: + - "on" + - "off" +enable_climate_react: + name: Enable Climate React + description: Enable and configure Climate React + target: + entity: + integration: sensibo + domain: climate + fields: + high_temperature_threshold: + name: Threshold high + description: When temp/humidity goes above + required: true + example: 24 + selector: + number: + min: 0 + max: 150 + step: 0.1 + mode: box + high_temperature_state: + name: State high threshold + description: What should happen at high threshold. Requires full state + required: true + selector: + object: + low_temperature_threshold: + name: Threshold low + description: When temp/humidity goes below + required: true + example: 19 + selector: + number: + min: 0 + max: 150 + step: 0.1 + mode: box + low_temperature_state: + name: State low threshold + description: What should happen at low threshold. Requires full state + required: true + selector: + object: + smart_type: + name: Trigger type + description: Choose between temperature/feels like/humidity + required: true + example: "temperature" + selector: + select: + options: + - "temperature" + - "feelsLike" + - "humidity" diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 19c6a7e594ac21867c29961af4e7d9d855ad1706..2af4e6043cbeae48a097894613d8224f498def74 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -17,7 +17,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "Follow the documentation to get your api key." + "api_key": "Follow the documentation to get your api key" } }, "reauth_confirm": { @@ -25,7 +25,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "Follow the documentation to get a new api key." + "api_key": "[%key:component::sensibo::config::step::user::data_description::api_key%]" } } } diff --git a/homeassistant/components/sensibo/strings.sensor.json b/homeassistant/components/sensibo/strings.sensor.json index 2e4e05fba5b37b5f3bdbbc1838181e55e862eb23..6226fd26a0f3500e83c3518f45f91727999b72bc 100644 --- a/homeassistant/components/sensibo/strings.sensor.json +++ b/homeassistant/components/sensibo/strings.sensor.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "Normal", "s": "Sensitive" + }, + "sensibo__smart_type": { + "temperature": "Temperature", + "feelslike": "Feels like", + "humidity": "Humidity" } } } diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index cefa8ece08404a82744574a37709faea07beb8a6..f57d72e5fb356f5e692d364af21306da6aaf2b28 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -14,6 +14,7 @@ from homeassistant.components.switch import ( ) 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 @@ -53,6 +54,17 @@ DEVICE_SWITCH_TYPES: tuple[SensiboDeviceSwitchEntityDescription, ...] = ( command_off="async_turn_off_timer", data_key="timer_on", ), + SensiboDeviceSwitchEntityDescription( + key="climate_react_switch", + device_class=SwitchDeviceClass.SWITCH, + name="Climate React", + icon="mdi:wizard-hat", + value_fn=lambda data: data.smart_on, + extra_fn=lambda data: {"type": data.smart_type}, + command_on="async_turn_on_off_smart", + command_off="async_turn_on_off_smart", + data_key="smart_on", + ), ) PURE_SWITCH_TYPES: tuple[SensiboDeviceSwitchEntityDescription, ...] = ( @@ -139,7 +151,6 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): @async_handle_api_call async def async_turn_on_timer(self, key: str, value: Any) -> bool: """Make service call to api for setting timer.""" - result = {} new_state = bool(self.device_data.ac_states["on"] is False) data = { "minutesFromNow": 60, @@ -151,14 +162,12 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): @async_handle_api_call async def async_turn_off_timer(self, key: str, value: Any) -> bool: """Make service call to api for deleting timer.""" - result = {} result = await self._client.async_del_timer(self._device_id) return bool(result.get("status") == "success") @async_handle_api_call async def async_turn_on_off_pure_boost(self, key: str, value: Any) -> bool: """Make service call to api for setting Pure Boost.""" - result = {} new_state = bool(self.device_data.pure_boost_enabled is False) data: dict[str, Any] = {"enabled": new_state} if self.device_data.pure_measure_integration is None: @@ -169,3 +178,15 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): data["primeIntegration"] = False result = await self._client.async_set_pureboost(self._device_id, data) return bool(result.get("status") == "success") + + @async_handle_api_call + async def async_turn_on_off_smart(self, key: str, value: Any) -> bool: + """Make service call to api for setting Climate React.""" + if self.device_data.smart_type is None: + raise HomeAssistantError( + "Use Sensibo Enable Climate React Service once to enable switch or the Sensibo app" + ) + new_state = bool(self.device_data.smart_on is False) + data: dict[str, Any] = {"enabled": new_state} + result = await self._client.async_enable_climate_react(self._device_id, data) + return bool(result.get("status") == "success") diff --git a/homeassistant/components/sensibo/translations/ca.json b/homeassistant/components/sensibo/translations/ca.json index 9429650dbee0a0dd4adc65d88478254e9935d722..44257e5e3f75717d8560e3275a2adf627ae27d92 100644 --- a/homeassistant/components/sensibo/translations/ca.json +++ b/homeassistant/components/sensibo/translations/ca.json @@ -17,7 +17,7 @@ "api_key": "Clau API" }, "data_description": { - "api_key": "Consulta la documentaci\u00f3 per obtenir una nova clau API." + "api_key": "Consulta la documentaci\u00f3 per obtenir la teva clau API" } }, "user": { @@ -25,7 +25,7 @@ "api_key": "Clau API" }, "data_description": { - "api_key": "Consulta la documentaci\u00f3 per obtenir la teva clau API." + "api_key": "Consulta la documentaci\u00f3 per obtenir la teva clau API" } } } diff --git a/homeassistant/components/sensibo/translations/de.json b/homeassistant/components/sensibo/translations/de.json index f6145f49998cd8af1983de720868a5a3a8da165c..13c258d40160cb0ff42a75366a07d4cfcd66ddfb 100644 --- a/homeassistant/components/sensibo/translations/de.json +++ b/homeassistant/components/sensibo/translations/de.json @@ -17,7 +17,7 @@ "api_key": "API-Schl\u00fcssel" }, "data_description": { - "api_key": "Folge der Dokumentation, um einen neuen Api-Schl\u00fcssel zu erhalten." + "api_key": "Folge der Dokumentation, um deinen API-Schl\u00fcssel zu erhalten." } }, "user": { @@ -25,7 +25,7 @@ "api_key": "API-Schl\u00fcssel" }, "data_description": { - "api_key": "Folge der Dokumentation, um deinen Api-Schl\u00fcssel zu erhalten." + "api_key": "Folge der Dokumentation, um deinen API-Schl\u00fcssel zu erhalten." } } } diff --git a/homeassistant/components/sensibo/translations/en.json b/homeassistant/components/sensibo/translations/en.json index 3f78e3be98d38ba0a2ef85f9b86f28eece224035..3b73823a2f7926e47c205436756184ffde36e755 100644 --- a/homeassistant/components/sensibo/translations/en.json +++ b/homeassistant/components/sensibo/translations/en.json @@ -17,7 +17,7 @@ "api_key": "API Key" }, "data_description": { - "api_key": "Follow the documentation to get a new api key." + "api_key": "Follow the documentation to get your api key" } }, "user": { @@ -25,7 +25,7 @@ "api_key": "API Key" }, "data_description": { - "api_key": "Follow the documentation to get your api key." + "api_key": "Follow the documentation to get your api key" } } } diff --git a/homeassistant/components/sensibo/translations/es.json b/homeassistant/components/sensibo/translations/es.json index 8c2c6bea7f36abbe654b199d49adb657198b3d00..3bc6910024de16b08c5c5859fe36f24cea023ae4 100644 --- a/homeassistant/components/sensibo/translations/es.json +++ b/homeassistant/components/sensibo/translations/es.json @@ -17,7 +17,7 @@ "api_key": "Clave API" }, "data_description": { - "api_key": "Sigue la documentaci\u00f3n para obtener una nueva clave API." + "api_key": "Sigue la documentaci\u00f3n para obtener tu clave API" } }, "user": { @@ -25,7 +25,7 @@ "api_key": "Clave API" }, "data_description": { - "api_key": "Sigue la documentaci\u00f3n para obtener tu clave API." + "api_key": "Sigue la documentaci\u00f3n para obtener tu clave API" } } } diff --git a/homeassistant/components/sensibo/translations/et.json b/homeassistant/components/sensibo/translations/et.json index acd3eb5ac1a671dca86f2d5852be3f8a55a547e8..803dcc01600f433f97b070f5d8212f7f250c6e0d 100644 --- a/homeassistant/components/sensibo/translations/et.json +++ b/homeassistant/components/sensibo/translations/et.json @@ -17,7 +17,7 @@ "api_key": "API v\u00f5ti" }, "data_description": { - "api_key": "Uue API-v\u00f5tme saamiseks j\u00e4rgi dokumentatsiooni." + "api_key": "API-v\u00f5tme saamiseks j\u00e4rgi dokumentatsiooni" } }, "user": { @@ -25,7 +25,7 @@ "api_key": "API v\u00f5ti" }, "data_description": { - "api_key": "API-v\u00f5tme hankimiseks j\u00e4rgi dokumentatsiooni." + "api_key": "API-v\u00f5tme hankimiseks j\u00e4rgi dokumentatsiooni" } } } diff --git a/homeassistant/components/sensibo/translations/fr.json b/homeassistant/components/sensibo/translations/fr.json index 5d53f8daa373cf55a5d51033ecad62b3be584379..3209ba2f2c869f4485b9176be7c50b6894b44fc1 100644 --- a/homeassistant/components/sensibo/translations/fr.json +++ b/homeassistant/components/sensibo/translations/fr.json @@ -17,7 +17,7 @@ "api_key": "Cl\u00e9 d'API" }, "data_description": { - "api_key": "Consultez la documentation pour obtenir une nouvelle cl\u00e9 d'API." + "api_key": "Consultez la documentation pour obtenir votre cl\u00e9 d'API" } }, "user": { @@ -25,7 +25,7 @@ "api_key": "Cl\u00e9 d'API" }, "data_description": { - "api_key": "Consultez la documentation pour obtenir votre cl\u00e9 d'API." + "api_key": "Consultez la documentation pour obtenir votre cl\u00e9 d'API" } } } diff --git a/homeassistant/components/sensibo/translations/hu.json b/homeassistant/components/sensibo/translations/hu.json index 66d277eecf5b4f8894941c49bbdfaf94576c8b5d..575fc6b71289aeea8203243567a76af4b6d1782e 100644 --- a/homeassistant/components/sensibo/translations/hu.json +++ b/homeassistant/components/sensibo/translations/hu.json @@ -17,7 +17,7 @@ "api_key": "API kulcs" }, "data_description": { - "api_key": "K\u00f6vesse a dokument\u00e1ci\u00f3t az \u00faj API-kulcs beszerz\u00e9s\u00e9hez." + "api_key": "K\u00f6vesse a dokument\u00e1ci\u00f3t az API-kulcs beszerz\u00e9s\u00e9hez." } }, "user": { diff --git a/homeassistant/components/sensibo/translations/no.json b/homeassistant/components/sensibo/translations/no.json index ec40be62e89f4b1efc76231fa78db2c6dfd78b4a..ebd4cb77b486a247749b1df8226f95420ec407f2 100644 --- a/homeassistant/components/sensibo/translations/no.json +++ b/homeassistant/components/sensibo/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", @@ -17,7 +17,7 @@ "api_key": "API-n\u00f8kkel" }, "data_description": { - "api_key": "F\u00f8lg dokumentasjonen for \u00e5 f\u00e5 en ny api-n\u00f8kkel." + "api_key": "F\u00f8lg dokumentasjonen for \u00e5 f\u00e5 api-n\u00f8kkelen din" } }, "user": { @@ -25,7 +25,7 @@ "api_key": "API-n\u00f8kkel" }, "data_description": { - "api_key": "F\u00f8lg dokumentasjonen for \u00e5 f\u00e5 api-n\u00f8kkelen din." + "api_key": "F\u00f8lg dokumentasjonen for \u00e5 f\u00e5 api-n\u00f8kkelen din" } } } diff --git a/homeassistant/components/sensibo/translations/pl.json b/homeassistant/components/sensibo/translations/pl.json index dc2fdda0065a684f3182158fa0293c853f676a1c..bd42df1e2fe1ad65e7c6345301375cd474ed096e 100644 --- a/homeassistant/components/sensibo/translations/pl.json +++ b/homeassistant/components/sensibo/translations/pl.json @@ -17,7 +17,7 @@ "api_key": "Klucz API" }, "data_description": { - "api_key": "Post\u0119puj zgodnie z dokumentacj\u0105, aby uzyska\u0107 nowy klucz API." + "api_key": "Post\u0119puj zgodnie z dokumentacj\u0105, aby uzyska\u0107 klucz API" } }, "user": { @@ -25,7 +25,7 @@ "api_key": "Klucz API" }, "data_description": { - "api_key": "Post\u0119puj zgodnie z dokumentacj\u0105, aby uzyska\u0107 klucz API." + "api_key": "Post\u0119puj zgodnie z dokumentacj\u0105, aby uzyska\u0107 klucz API" } } } diff --git a/homeassistant/components/sensibo/translations/pt-BR.json b/homeassistant/components/sensibo/translations/pt-BR.json index 591424dcaf68dab89e118910b82154b964830344..0429945137c1c1ebc59d646e2395d22e56200fb4 100644 --- a/homeassistant/components/sensibo/translations/pt-BR.json +++ b/homeassistant/components/sensibo/translations/pt-BR.json @@ -17,7 +17,7 @@ "api_key": "Chave da API" }, "data_description": { - "api_key": "Siga a documenta\u00e7\u00e3o para obter uma nova chave de API." + "api_key": "Siga a documenta\u00e7\u00e3o para obter sua chave de API" } }, "user": { @@ -25,7 +25,7 @@ "api_key": "Chave da API" }, "data_description": { - "api_key": "Siga a documenta\u00e7\u00e3o para obter sua chave de API." + "api_key": "Siga a documenta\u00e7\u00e3o para obter sua chave de API" } } } diff --git a/homeassistant/components/sensibo/translations/sensor.bg.json b/homeassistant/components/sensibo/translations/sensor.bg.json new file mode 100644 index 0000000000000000000000000000000000000000..bae9aca4e355ab9994a4727a0262c6c7f97e43af --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.bg.json @@ -0,0 +1,12 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "\u041d\u043e\u0440\u043c\u0430\u043b\u0435\u043d", + "s": "\u0427\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d" + }, + "sensibo__smart_type": { + "humidity": "\u0412\u043b\u0430\u0436\u043d\u043e\u0441\u0442", + "temperature": "\u0422\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.ca.json b/homeassistant/components/sensibo/translations/sensor.ca.json index 1b251c70f7d6e0d7de2615d37907818d463ff4f1..ea5340076ebb19e3152f6015f58c67cd58ac9253 100644 --- a/homeassistant/components/sensibo/translations/sensor.ca.json +++ b/homeassistant/components/sensibo/translations/sensor.ca.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "Normal", "s": "Sensible" + }, + "sensibo__smart_type": { + "feelslike": "Sensaci\u00f3 de", + "humidity": "Humitat", + "temperature": "Temperatura" } } } \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.de.json b/homeassistant/components/sensibo/translations/sensor.de.json index ab456f555af40d50516e7d0ff71a16cbfe5d2b55..07cd377de7c07304cc73e54a7aaaa5c9a8e19f1a 100644 --- a/homeassistant/components/sensibo/translations/sensor.de.json +++ b/homeassistant/components/sensibo/translations/sensor.de.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "Normal", "s": "Empfindlich" + }, + "sensibo__smart_type": { + "feelslike": "F\u00fchlt sich an wie", + "humidity": "Luftfeuchtigkeit", + "temperature": "Temperatur" } } } \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.en.json b/homeassistant/components/sensibo/translations/sensor.en.json index 9ea1818b37c78f3d3c98933cd7f01232d82dfaf6..b846d6a2074172512ea3ea71946d5c9d8c57b607 100644 --- a/homeassistant/components/sensibo/translations/sensor.en.json +++ b/homeassistant/components/sensibo/translations/sensor.en.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "Normal", "s": "Sensitive" + }, + "sensibo__smart_type": { + "feelslike": "Feels like", + "humidity": "Humidity", + "temperature": "Temperature" } } } \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.es.json b/homeassistant/components/sensibo/translations/sensor.es.json index 1b251c70f7d6e0d7de2615d37907818d463ff4f1..d55b36fcf52990b67ba06c839f851d53cb3b1375 100644 --- a/homeassistant/components/sensibo/translations/sensor.es.json +++ b/homeassistant/components/sensibo/translations/sensor.es.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "Normal", "s": "Sensible" + }, + "sensibo__smart_type": { + "feelslike": "Se siente como", + "humidity": "Humedad", + "temperature": "Temperatura" } } } \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.et.json b/homeassistant/components/sensibo/translations/sensor.et.json index 44bdfe9183a8b19685a820bca4a3b83dceacdefa..16e7eb1bb894dcfb81f1484ec6e76bbdbcca4ebe 100644 --- a/homeassistant/components/sensibo/translations/sensor.et.json +++ b/homeassistant/components/sensibo/translations/sensor.et.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "Tavaline", "s": "Tundlik" + }, + "sensibo__smart_type": { + "feelslike": "Tundub nagu", + "humidity": "Niiskus", + "temperature": "Temperatuur" } } } \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.fr.json b/homeassistant/components/sensibo/translations/sensor.fr.json index 1b251c70f7d6e0d7de2615d37907818d463ff4f1..ffbdb551c9512023b0c7bcf2c5f898f7df5032dd 100644 --- a/homeassistant/components/sensibo/translations/sensor.fr.json +++ b/homeassistant/components/sensibo/translations/sensor.fr.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "Normal", "s": "Sensible" + }, + "sensibo__smart_type": { + "feelslike": "Ressenti", + "humidity": "Humidit\u00e9", + "temperature": "Temp\u00e9rature" } } } \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.hu.json b/homeassistant/components/sensibo/translations/sensor.hu.json index 38a372f0f78edee65cc26636b225e3395ef7499e..093ce31a3e12ce5b2abe4e1e07c915f482f8aab1 100644 --- a/homeassistant/components/sensibo/translations/sensor.hu.json +++ b/homeassistant/components/sensibo/translations/sensor.hu.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "Norm\u00e1l", "s": "\u00c9rz\u00e9keny" + }, + "sensibo__smart_type": { + "feelslike": "\u00c9rz\u00e9sre", + "humidity": "P\u00e1ratartalom", + "temperature": "H\u0151m\u00e9rs\u00e9klet" } } } \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.it.json b/homeassistant/components/sensibo/translations/sensor.it.json index 85550808ee95e13feb5ab29cefa4533a1adefdfe..cb611c4cf42edb20f78950b76a3b110ad3f93b38 100644 --- a/homeassistant/components/sensibo/translations/sensor.it.json +++ b/homeassistant/components/sensibo/translations/sensor.it.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "Normale", "s": "Sensibile" + }, + "sensibo__smart_type": { + "feelslike": "Sembra che", + "humidity": "Umidit\u00e0", + "temperature": "Temperatura" } } } \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.no.json b/homeassistant/components/sensibo/translations/sensor.no.json index e3de20b46369282f24bacee28e47b2753b469ef6..f70217ca2b9ea203e1f101e41aaeab81ee275740 100644 --- a/homeassistant/components/sensibo/translations/sensor.no.json +++ b/homeassistant/components/sensibo/translations/sensor.no.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "Vanlig", "s": "F\u00f8lsom" + }, + "sensibo__smart_type": { + "feelslike": "F\u00f8les som", + "humidity": "Fuktighet", + "temperature": "Temperatur" } } } \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.pl.json b/homeassistant/components/sensibo/translations/sensor.pl.json index 1b8ed8a8ed7c697b50c7d437f18fb7d3112f8b7e..bf55a86f303c982340d206cc3dbe88575e880c87 100644 --- a/homeassistant/components/sensibo/translations/sensor.pl.json +++ b/homeassistant/components/sensibo/translations/sensor.pl.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "normalna", "s": "wysoka" + }, + "sensibo__smart_type": { + "feelslike": "odczuwalna", + "humidity": "wilgotno\u015b\u0107", + "temperature": "temperatura" } } } \ 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 index 91d092a1760c9156fb39c92730eee64aa290fb8f..6b12febda32ca5c2ba387ae5c87695e0072e5f26 100644 --- a/homeassistant/components/sensibo/translations/sensor.pt-BR.json +++ b/homeassistant/components/sensibo/translations/sensor.pt-BR.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "Normal", "s": "Sens\u00edvel" + }, + "sensibo__smart_type": { + "feelslike": "Parece que", + "humidity": "Umidade", + "temperature": "Temperatura" } } } \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.ru.json b/homeassistant/components/sensibo/translations/sensor.ru.json index c916a2b22f4c93a55af1d85347e704b32995bbd0..74722e0358a6b5724b1c7128ac273b476b3d02de 100644 --- a/homeassistant/components/sensibo/translations/sensor.ru.json +++ b/homeassistant/components/sensibo/translations/sensor.ru.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "\u041d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u044b\u0439", "s": "\u0427\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439" + }, + "sensibo__smart_type": { + "feelslike": "\u041e\u0449\u0443\u0449\u0430\u0435\u0442\u0441\u044f \u043a\u0430\u043a", + "humidity": "\u0412\u043b\u0430\u0436\u043d\u043e\u0441\u0442\u044c", + "temperature": "\u0422\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430" } } } \ 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 index 5144fdcc69964fffaf30e82bc2ae54da4fda9fbe..a98e9d8000a219df6323dcc206caaecf9be031ef 100644 --- a/homeassistant/components/sensibo/translations/sensor.zh-Hant.json +++ b/homeassistant/components/sensibo/translations/sensor.zh-Hant.json @@ -3,6 +3,11 @@ "sensibo__sensitivity": { "n": "\u6b63\u5e38", "s": "\u654f\u611f" + }, + "sensibo__smart_type": { + "feelslike": "\u9ad4\u611f", + "humidity": "\u6fd5\u5ea6", + "temperature": "\u6eab\u5ea6" } } } \ No newline at end of file diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 06071eeddbe5c566f514d9c5d64c608c509c7761..6ba88defc839f90f44e653dcf69f2cd559672cc9 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -1,6 +1,7 @@ """Component to interface with various sensors that can be monitored.""" from __future__ import annotations +import asyncio from collections.abc import Mapping from contextlib import suppress from dataclasses import dataclass @@ -49,12 +50,14 @@ from homeassistant.const import ( # noqa: F401, pylint: disable=[hass-deprecate TEMP_KELVIN, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er 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.entity_platform import EntityPlatform from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util import dt as dt_util @@ -85,116 +88,268 @@ SCAN_INTERVAL: Final = timedelta(seconds=30) class SensorDeviceClass(StrEnum): """Device class for sensors.""" - # apparent power (VA) APPARENT_POWER = "apparent_power" + """Apparent power. + + Unit of measurement: `VA` + """ - # Air Quality Index AQI = "aqi" + """Air Quality Index. + + Unit of measurement: `None` + """ - # % of battery that is left BATTERY = "battery" + """Percentage of battery that is left. + + Unit of measurement: `%` + """ - # ppm (parts per million) Carbon Monoxide gas concentration CO = "carbon_monoxide" + """Carbon Monoxide gas concentration. + + Unit of measurement: `ppm` (parts per million) + """ - # ppm (parts per million) Carbon Dioxide gas concentration CO2 = "carbon_dioxide" + """Carbon Dioxide gas concentration. + + Unit of measurement: `ppm` (parts per million) + """ - # current (A) CURRENT = "current" + """Current. + + Unit of measurement: `A` + """ - # date (ISO8601) DATE = "date" + """Date. + + Unit of measurement: `None` + + ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601 + """ - # distance (LENGTH_*) DISTANCE = "distance" + """Generic distance. + + Unit of measurement: `LENGTH_*` units + - SI /metric: `mm`, `cm`, `m`, `km` + - USCS / imperial: `in`, `ft`, `yd`, `mi` + """ - # fixed duration (TIME_DAYS, TIME_HOURS, TIME_MINUTES, TIME_SECONDS) DURATION = "duration" + """Fixed duration. + + Unit of measurement: `d`, `h`, `min`, `s` + """ - # energy (Wh, kWh, MWh) ENERGY = "energy" + """Energy. + + Unit of measurement: `Wh`, `kWh`, `MWh`, `GJ` + """ - # frequency (Hz, kHz, MHz, GHz) FREQUENCY = "frequency" + """Frequency. + + Unit of measurement: `Hz`, `kHz`, `MHz`, `GHz` + """ - # gas (m³ or ft³) GAS = "gas" + """Gas. + + Unit of measurement: `m³`, `ft³` + """ - # Relative humidity (%) HUMIDITY = "humidity" + """Relative humidity. + + Unit of measurement: `%` + """ - # current light level (lx/lm) ILLUMINANCE = "illuminance" + """Illuminance. + + Unit of measurement: `lx`, `lm` + """ - # moisture (%) MOISTURE = "moisture" + """Moisture. + + Unit of measurement: `%` + """ - # Amount of money (currency) MONETARY = "monetary" + """Amount of money. + + Unit of measurement: ISO4217 currency code + + See https://en.wikipedia.org/wiki/ISO_4217#Active_codes for active codes + """ - # Amount of NO2 (µg/m³) NITROGEN_DIOXIDE = "nitrogen_dioxide" + """Amount of NO2. + + Unit of measurement: `µg/m³` + """ - # Amount of NO (µg/m³) NITROGEN_MONOXIDE = "nitrogen_monoxide" + """Amount of NO. + + Unit of measurement: `µg/m³` + """ - # Amount of N2O (µg/m³) NITROUS_OXIDE = "nitrous_oxide" + """Amount of N2O. + + Unit of measurement: `µg/m³` + """ - # Amount of O3 (µg/m³) OZONE = "ozone" + """Amount of O3. + + Unit of measurement: `µg/m³` + """ - # Particulate matter <= 0.1 μm (µg/m³) PM1 = "pm1" + """Particulate matter <= 0.1 μm. + + Unit of measurement: `µg/m³` + """ - # Particulate matter <= 10 μm (µg/m³) PM10 = "pm10" + """Particulate matter <= 10 μm. + + Unit of measurement: `µg/m³` + """ - # Particulate matter <= 2.5 μm (µg/m³) PM25 = "pm25" + """Particulate matter <= 2.5 μm. + + Unit of measurement: `µg/m³` + """ - # power factor (%) POWER_FACTOR = "power_factor" + """Power factor. + + Unit of measurement: `%` + """ - # power (W/kW) POWER = "power" + """Power. + + Unit of measurement: `W`, `kW` + """ + + PRECIPITATION_INTENSITY = "precipitation_intensity" + """Precipitation intensity. + + Unit of measurement: UnitOfVolumetricFlux + - SI /metric: `mm/d`, `mm/h` + - USCS / imperial: `in/d`, `in/h` + """ - # pressure (hPa/mbar) PRESSURE = "pressure" + """Pressure. + + Unit of measurement: + - `mbar`, `cbar`, `bar` + - `Pa`, `hPa`, `kPa` + - `inHg` + - `psi` + """ - # reactive power (var) REACTIVE_POWER = "reactive_power" + """Reactive power. + + Unit of measurement: `var` + """ - # signal strength (dB/dBm) SIGNAL_STRENGTH = "signal_strength" + """Signal strength. + + Unit of measurement: `dB`, `dBm` + """ - # speed (SPEED_*) SPEED = "speed" + """Generic speed. + + Unit of measurement: `SPEED_*` units or `UnitOfVolumetricFlux` + - SI /metric: `mm/d`, `mm/h`, `m/s`, `km/h` + - USCS / imperial: `in/d`, `in/h`, `ft/s`, `mph` + - Nautical: `kn` + """ - # Amount of SO2 (µg/m³) SULPHUR_DIOXIDE = "sulphur_dioxide" + """Amount of SO2. + + Unit of measurement: `µg/m³` + """ - # temperature (C/F) TEMPERATURE = "temperature" + """Temperature. + + Unit of measurement: `°C`, `°F` + """ - # timestamp (ISO8601) TIMESTAMP = "timestamp" + """Timestamp. + + Unit of measurement: `None` + + ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601 + """ - # Amount of VOC (µg/m³) VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" + """Amount of VOC. + + Unit of measurement: `µg/m³` + """ - # voltage (V) VOLTAGE = "voltage" + """Voltage. + + Unit of measurement: `V` + """ - # volume (VOLUME_*) VOLUME = "volume" + """Generic volume. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `fl. oz.`, `ft³`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + + WATER = "water" + """Water. + + Unit of measurement: + - SI / metric: `m³`, `L` + - USCS / imperial: `ft³`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ - # weight/mass (g, kg, mg, µg, oz, lb) WEIGHT = "weight" - """Represent a measurement of an object's mass. + """Generic weight, represents a measurement of an object's mass. Weight is used instead of mass to fit with every day language. + + Unit of measurement: `MASS_*` units + - SI / metric: `µg`, `mg`, `g`, `kg` + - USCS / imperial: `oz`, `lb` + """ + + WIND_SPEED = "wind_speed" + """Wind speed. + + Unit of measurement: `SPEED_*` units + - SI /metric: `m/s`, `km/h` + - USCS / imperial: `ft/s`, `mph` + - Nautical: `kn` """ @@ -208,14 +363,18 @@ DEVICE_CLASSES: Final[list[str]] = [cls.value for cls in SensorDeviceClass] class SensorStateClass(StrEnum): """State class for sensors.""" - # The state represents a measurement in present time MEASUREMENT = "measurement" + """The state represents a measurement in present time.""" - # The state represents a total amount, e.g. net energy consumption TOTAL = "total" + """The state represents a total amount. + + For example: net energy consumption""" - # The state represents a monotonically increasing total, e.g. an amount of consumed gas TOTAL_INCREASING = "total_increasing" + """The state represents a monotonically increasing total. + + For example: an amount of consumed gas""" STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorStateClass)) @@ -228,13 +387,18 @@ STATE_CLASS_TOTAL: Final = "total" STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] -UNIT_CONVERTERS: dict[str, type[BaseUnitConverter]] = { +# Note: this needs to be aligned with frontend: OVERRIDE_SENSOR_UNITS in +# `entity-registry-settings.ts` +UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.DISTANCE: DistanceConverter, + SensorDeviceClass.GAS: VolumeConverter, SensorDeviceClass.PRESSURE: PressureConverter, SensorDeviceClass.SPEED: SpeedConverter, SensorDeviceClass.TEMPERATURE: TemperatureConverter, SensorDeviceClass.VOLUME: VolumeConverter, + SensorDeviceClass.WATER: VolumeConverter, SensorDeviceClass.WEIGHT: MassConverter, + SensorDeviceClass.WIND_SPEED: SpeedConverter, } # mypy: disallow-any-generics @@ -267,6 +431,7 @@ class SensorEntityDescription(EntityDescription): """A class that describes sensor entities.""" device_class: SensorDeviceClass | str | None = None + suggested_unit_of_measurement: str | None = None last_reset: datetime | None = None native_unit_of_measurement: str | None = None state_class: SensorStateClass | str | None = None @@ -283,15 +448,53 @@ class SensorEntity(Entity): _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_suggested_unit_of_measurement: str | None _attr_unit_of_measurement: None = ( None # Subclasses of SensorEntity should not set this ) _last_reset_reported = False - _temperature_conversion_reported = False _sensor_option_unit_of_measurement: str | None = None - # Temporary private attribute to track if deprecation has been logged. - __datetime_as_string_deprecation_logged = False + @callback + def add_to_platform_start( + self, + hass: HomeAssistant, + platform: EntityPlatform, + parallel_updates: asyncio.Semaphore | None, + ) -> None: + """Start adding an entity to a platform.""" + super().add_to_platform_start(hass, platform, parallel_updates) + + if self.unique_id is None: + return + registry = er.async_get(self.hass) + if not ( + entity_id := registry.async_get_entity_id( + platform.domain, platform.platform_name, self.unique_id + ) + ): + return + registry_entry = registry.async_get(entity_id) + assert registry_entry + + # Store unit override according to automatic unit conversion rules if: + # - no unit override is stored in the entity registry + # - units have changed + # - the unit stored in the registry matches automatic unit conversion rules + # This allows integrations to drop custom unit conversion and rely on automatic + # conversion. + registry_unit = registry_entry.unit_of_measurement + if ( + DOMAIN not in registry_entry.options + and f"{DOMAIN}.private" not in registry_entry.options + and self.unit_of_measurement != registry_unit + and (suggested_unit := self._get_initial_suggested_unit()) == registry_unit + ): + registry.async_update_entity_options( + entity_id, + f"{DOMAIN}.private", + {"suggested_unit_of_measurement": suggested_unit}, + ) async def async_internal_added_to_hass(self) -> None: """Call when the sensor entity is added to hass.""" @@ -335,6 +538,35 @@ class SensorEntity(Entity): return None + def _get_initial_suggested_unit(self) -> str | None: + """Return initial suggested unit of measurement.""" + # Unit suggested by the integration + suggested_unit_of_measurement = self.suggested_unit_of_measurement + + if suggested_unit_of_measurement is None: + # Fallback to suggested by the unit conversion rules + suggested_unit_of_measurement = self.hass.config.units.get_converted_unit( + self.device_class, self.native_unit_of_measurement + ) + + return suggested_unit_of_measurement + + def get_initial_entity_options(self) -> er.EntityOptionsType | None: + """Return initial entity options. + + These will be stored in the entity registry the first time the entity is seen, + and then never updated. + """ + suggested_unit_of_measurement = self._get_initial_suggested_unit() + if suggested_unit_of_measurement is None: + return None + + return { + f"{DOMAIN}.private": { + "suggested_unit_of_measurement": suggested_unit_of_measurement + } + } + @final @property def state_attributes(self) -> dict[str, Any] | None: @@ -378,13 +610,45 @@ class SensorEntity(Entity): return self.entity_description.native_unit_of_measurement return None + @property + def suggested_unit_of_measurement(self) -> str | None: + """Return the unit which should be used for the sensor's state. + + This can be used by integrations to override automatic unit conversion rules, + for example to make a temperature sensor display in °C even if the configured + unit system prefers °F. + + For sensors without a `unique_id`, this takes precedence over legacy + temperature conversion rules only. + + For sensors with a `unique_id`, this is applied only if the unit is not set by the user, + and takes precedence over automatic device-class conversion rules. + + Note: + suggested_unit_of_measurement is stored in the entity registry the first time + the entity is seen, and then never updated. + """ + if hasattr(self, "_attr_suggested_unit_of_measurement"): + return self._attr_suggested_unit_of_measurement + if hasattr(self, "entity_description"): + return self.entity_description.suggested_unit_of_measurement + return None + @final @property def unit_of_measurement(self) -> str | None: """Return the unit of measurement of the entity, after unit conversion.""" + # Highest priority, for registered entities: unit set by user, with fallback to unit suggested + # by integration or secondary fallback to unit conversion rules if self._sensor_option_unit_of_measurement: return self._sensor_option_unit_of_measurement + # Second priority, for non registered entities: unit suggested by integration + if not self.registry_entry and self.suggested_unit_of_measurement: + return self.suggested_unit_of_measurement + + # Third priority: Legacy temperature conversion, which applies + # to both registered and non registered entities native_unit_of_measurement = self.native_unit_of_measurement if ( @@ -393,6 +657,7 @@ class SensorEntity(Entity): ): return self.hass.config.units.temperature_unit + # Fourth priority: Native unit return native_unit_of_measurement @final @@ -488,22 +753,30 @@ class SensorEntity(Entity): return super().__repr__() - @callback - def async_registry_entry_updated(self) -> None: - """Run when the entity registry entry has been updated.""" + def _custom_unit_or_none(self, primary_key: str, secondary_key: str) -> str | None: + """Return a custom unit, or None if it's not compatible with the native unit.""" assert self.registry_entry if ( - (sensor_options := self.registry_entry.options.get(DOMAIN)) - and (custom_unit := sensor_options.get(CONF_UNIT_OF_MEASUREMENT)) + (sensor_options := self.registry_entry.options.get(primary_key)) + and (custom_unit := sensor_options.get(secondary_key)) and (device_class := self.device_class) in UNIT_CONVERTERS and self.native_unit_of_measurement in UNIT_CONVERTERS[device_class].VALID_UNITS and custom_unit in UNIT_CONVERTERS[device_class].VALID_UNITS ): - self._sensor_option_unit_of_measurement = custom_unit - return + return cast(str, custom_unit) + return None - self._sensor_option_unit_of_measurement = None + @callback + def async_registry_entry_updated(self) -> None: + """Run when the entity registry entry has been updated.""" + self._sensor_option_unit_of_measurement = self._custom_unit_or_none( + DOMAIN, CONF_UNIT_OF_MEASUREMENT + ) + if not self._sensor_option_unit_of_measurement: + self._sensor_option_unit_of_measurement = self._custom_unit_or_none( + f"{DOMAIN}.private", "suggested_unit_of_measurement" + ) @dataclass diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 93ba51d26688f358551558c89f8235eb0e89fe62..e06355dda8c318f60705d005a709893b7e106a9e 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -52,6 +52,7 @@ CONF_IS_PM10 = "is_pm10" CONF_IS_PM25 = "is_pm25" CONF_IS_POWER = "is_power" CONF_IS_POWER_FACTOR = "is_power_factor" +CONF_IS_PRECIPITATION_INTENSITY = "is_precipitation_intensity" CONF_IS_PRESSURE = "is_pressure" CONF_IS_SPEED = "is_speed" CONF_IS_REACTIVE_POWER = "is_reactive_power" @@ -62,7 +63,9 @@ CONF_IS_VALUE = "is_value" CONF_IS_VOLATILE_ORGANIC_COMPOUNDS = "is_volatile_organic_compounds" CONF_IS_VOLTAGE = "is_voltage" CONF_IS_VOLUME = "is_volume" +CONF_IS_WATER = "is_water" CONF_IS_WEIGHT = "is_weight" +CONF_IS_WIND_SPEED = "is_wind_speed" ENTITY_CONDITIONS = { SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_IS_APPARENT_POWER}], @@ -86,6 +89,9 @@ ENTITY_CONDITIONS = { SensorDeviceClass.PM1: [{CONF_TYPE: CONF_IS_PM1}], SensorDeviceClass.PM10: [{CONF_TYPE: CONF_IS_PM10}], SensorDeviceClass.PM25: [{CONF_TYPE: CONF_IS_PM25}], + SensorDeviceClass.PRECIPITATION_INTENSITY: [ + {CONF_TYPE: CONF_IS_PRECIPITATION_INTENSITY} + ], SensorDeviceClass.PRESSURE: [{CONF_TYPE: CONF_IS_PRESSURE}], SensorDeviceClass.REACTIVE_POWER: [{CONF_TYPE: CONF_IS_REACTIVE_POWER}], SensorDeviceClass.SIGNAL_STRENGTH: [{CONF_TYPE: CONF_IS_SIGNAL_STRENGTH}], @@ -97,7 +103,9 @@ ENTITY_CONDITIONS = { ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_IS_VOLUME}], + SensorDeviceClass.WATER: [{CONF_TYPE: CONF_IS_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_IS_WEIGHT}], + SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_IS_WIND_SPEED}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_IS_VALUE}], } diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 9e433cea31b749c794a88ae1f808e31b706cbeb9..4f36c8b78cb27207915992b43460740322446295 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -51,6 +51,7 @@ CONF_PM10 = "pm10" CONF_PM25 = "pm25" CONF_POWER = "power" CONF_POWER_FACTOR = "power_factor" +CONF_PRECIPITATION_INTENSITY = "precipitation_intensity" CONF_PRESSURE = "pressure" CONF_REACTIVE_POWER = "reactive_power" CONF_SIGNAL_STRENGTH = "signal_strength" @@ -61,7 +62,9 @@ CONF_VALUE = "value" CONF_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" CONF_VOLTAGE = "voltage" CONF_VOLUME = "volume" +CONF_WATER = "water" CONF_WEIGHT = "weight" +CONF_WIND_SPEED = "wind_speed" ENTITY_TRIGGERS = { SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_APPARENT_POWER}], @@ -85,6 +88,9 @@ ENTITY_TRIGGERS = { SensorDeviceClass.PM25: [{CONF_TYPE: CONF_PM25}], SensorDeviceClass.POWER: [{CONF_TYPE: CONF_POWER}], SensorDeviceClass.POWER_FACTOR: [{CONF_TYPE: CONF_POWER_FACTOR}], + SensorDeviceClass.PRECIPITATION_INTENSITY: [ + {CONF_TYPE: CONF_PRECIPITATION_INTENSITY} + ], SensorDeviceClass.PRESSURE: [{CONF_TYPE: CONF_PRESSURE}], SensorDeviceClass.REACTIVE_POWER: [{CONF_TYPE: CONF_REACTIVE_POWER}], SensorDeviceClass.SIGNAL_STRENGTH: [{CONF_TYPE: CONF_SIGNAL_STRENGTH}], @@ -96,7 +102,9 @@ ENTITY_TRIGGERS = { ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_VOLUME}], + SensorDeviceClass.WATER: [{CONF_TYPE: CONF_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_WEIGHT}], + SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_WIND_SPEED}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_VALUE}], } diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index beae06f78fff6375b23c352c6a18efb627ef3f49..5e853ec3c741070ce0b3a8c80e05ff78f95dec85 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -23,8 +23,13 @@ from homeassistant.components.recorder.models import ( StatisticMetaData, StatisticResult, ) -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT -from homeassistant.core import HomeAssistant, State +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + REVOLUTIONS_PER_MINUTE, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, +) +from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources from homeassistant.util import dt as dt_util @@ -47,6 +52,12 @@ DEFAULT_STATISTICS = { STATE_CLASS_TOTAL_INCREASING: {"sum"}, } +EQUIVALENT_UNITS = { + "RPM": REVOLUTIONS_PER_MINUTE, + "ft3": VOLUME_CUBIC_FEET, + "m3": VOLUME_CUBIC_METERS, +} + # Keep track of entities for which a warning about decreasing value has been logged SEEN_DIP = "sensor_seen_total_increasing_dip" WARN_DIP = "sensor_warn_total_increasing_dip" @@ -113,10 +124,20 @@ def _time_weighted_average( def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: - """Return True if all states have the same unit.""" + """Return a set of all units.""" return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates} +def _equivalent_units(units: set[str | None]) -> bool: + """Return True if the units are equivalent.""" + if len(units) == 1: + return True + units = { + EQUIVALENT_UNITS[unit] if unit in EQUIVALENT_UNITS else unit for unit in units + } + return len(units) == 1 + + def _parse_float(state: str) -> float: """Parse a float string, throw on inf or nan.""" fstate = float(state) @@ -131,7 +152,7 @@ def _normalize_states( old_metadatas: dict[str, tuple[int, StatisticMetaData]], entity_history: Iterable[State], entity_id: str, -) -> tuple[str | None, str | None, list[tuple[float, State]]]: +) -> tuple[str | None, list[tuple[float, State]]]: """Normalize units.""" old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None state_unit: str | None = None @@ -145,7 +166,7 @@ def _normalize_states( fstates.append((fstate, state)) if not fstates: - return None, None, fstates + return None, fstates state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -165,7 +186,7 @@ def _normalize_states( # The unit used by this sensor doesn't support unit conversion all_units = _get_units(fstates) - if len(all_units) > 1: + if not _equivalent_units(all_units): if WARN_UNSTABLE_UNIT not in hass.data: hass.data[WARN_UNSTABLE_UNIT] = set() if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: @@ -185,9 +206,9 @@ def _normalize_states( extra, LINK_DEV_STATISTICS, ) - return None, None, [] + return None, [] state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) - return state_unit, state_unit, fstates + return state_unit, fstates converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER[statistics_unit] valid_fstates: list[tuple[float, State]] = [] @@ -223,7 +244,7 @@ def _normalize_states( ) ) - return statistics_unit, state_unit, valid_fstates + return statistics_unit, valid_fstates def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str: @@ -366,7 +387,7 @@ def _compile_statistics( # noqa: C901 sensor_states = _get_sensor_states(hass) wanted_statistics = _wanted_statistics(sensor_states) old_metadatas = statistics.get_metadata_with_session( - hass, session, statistic_ids=[i.entity_id for i in sensor_states] + session, statistic_ids=[i.entity_id for i in sensor_states] ) # Get history between start and end @@ -411,7 +432,7 @@ def _compile_statistics( # noqa: C901 continue entity_history = history_list[entity_id] - statistics_unit, state_unit, fstates = _normalize_states( + statistics_unit, fstates = _normalize_states( hass, session, old_metadatas, @@ -424,9 +445,7 @@ def _compile_statistics( # noqa: C901 state_class = _state.attributes[ATTR_STATE_CLASS] - to_process.append( - (entity_id, statistics_unit, state_unit, state_class, fstates) - ) + to_process.append((entity_id, statistics_unit, state_class, fstates)) if "sum" in wanted_statistics[entity_id]: to_query.append(entity_id) @@ -436,13 +455,14 @@ def _compile_statistics( # noqa: C901 for ( # pylint: disable=too-many-nested-blocks entity_id, statistics_unit, - state_unit, state_class, fstates, ) in to_process: # Check metadata if old_metadata := old_metadatas.get(entity_id): - if old_metadata[1]["unit_of_measurement"] != statistics_unit: + if not _equivalent_units( + {old_metadata[1]["unit_of_measurement"], statistics_unit} + ): if WARN_UNSTABLE_UNIT not in hass.data: hass.data[WARN_UNSTABLE_UNIT] = set() if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: @@ -651,7 +671,7 @@ def validate_statistics( metadata_unit = metadata[1]["unit_of_measurement"] converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER.get(metadata_unit) if not converter: - if state_unit != metadata_unit: + if not _equivalent_units({state_unit, metadata_unit}): # The unit has changed, and it's not possible to convert validation_result[entity_id].append( statistics.ValidationIssue( @@ -689,6 +709,8 @@ def validate_statistics( ) for statistic_id in sensor_statistic_ids - sensor_entity_ids: + if split_entity_id(statistic_id)[0] != DOMAIN: + continue # There is no sensor matching the statistics_id validation_result[statistic_id].append( statistics.ValidationIssue( diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 3584777bb5fc2f2c36bdebcd0d83f9db33bc853c..12c902816b1f343b9e1805f59fc8f9eaf70314d7 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -33,6 +33,7 @@ "is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level", "is_voltage": "Current {entity_name} voltage", "is_volume": "Current {entity_name} volume", + "is_water": "Current {entity_name} water", "is_weight": "Current {entity_name} weight" }, "trigger_type": { @@ -67,6 +68,7 @@ "volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes", "voltage": "{entity_name} voltage changes", "volume": "{entity_name} volume changes", + "water": "{entity_name} water changes", "weight": "{entity_name} weight changes" } }, diff --git a/homeassistant/components/sensor/translations/ca.json b/homeassistant/components/sensor/translations/ca.json index df4bcff1c53a62770a5c2bf6d1097a2113e49dbf..18517b480666a37d843e67f8135b2a02e55f5f08 100644 --- a/homeassistant/components/sensor/translations/ca.json +++ b/homeassistant/components/sensor/translations/ca.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "Concentraci\u00f3 actual de di\u00f2xid de carboni de {entity_name}", "is_carbon_monoxide": "Concentraci\u00f3 actual de mon\u00f2xid de carboni de {entity_name}", "is_current": "Intensitat actual de {entity_name}", + "is_distance": "Dist\u00e0ncia actual de {entity_name}", "is_energy": "Energia actual de {entity_name}", "is_frequency": "Freq\u00fc\u00e8ncia actual de {entity_name}", "is_gas": "Gas actual de {entity_name}", @@ -24,11 +25,15 @@ "is_pressure": "Pressi\u00f3 actual de {entity_name}", "is_reactive_power": "Pot\u00e8ncia reactiva actual de {entity_name}", "is_signal_strength": "Pot\u00e8ncia de senyal actual de {entity_name}", + "is_speed": "Velocitat actual de {entity_name}", "is_sulphur_dioxide": "Concentraci\u00f3 actual de di\u00f2xid de sofre de {entity_name}", "is_temperature": "Temperatura actual de {entity_name}", "is_value": "Valor actual de {entity_name}", "is_volatile_organic_compounds": "Concentraci\u00f3 actual de compostos org\u00e0nics vol\u00e0tils de {entity_name}", - "is_voltage": "Voltatge actual de {entity_name}" + "is_voltage": "Voltatge actual de {entity_name}", + "is_volume": "Volum actual de {entity_name}", + "is_water": "Aigua actual de {entity_name}", + "is_weight": "Pes actual de {entity_name}" }, "trigger_type": { "apparent_power": "Canvia la pot\u00e8ncia aparent de {entity_name}", @@ -36,6 +41,7 @@ "carbon_dioxide": "Canvia la concentraci\u00f3 de di\u00f2xid de carboni de {entity_name}", "carbon_monoxide": "Canvia la concentraci\u00f3 de mon\u00f2xid de carboni de {entity_name}", "current": "Canvia la intensitat de {entity_name}", + "distance": "Canvia la dist\u00e0ncia de {entity_name}", "energy": "Canvia l'energia de {entity_name}", "frequency": "Canvia la freq\u00fc\u00e8ncia de {entity_name}", "gas": "Canvia el gas de {entity_name}", @@ -54,11 +60,15 @@ "pressure": "Canvia la pressi\u00f3 de {entity_name}", "reactive_power": "Canvia la pot\u00e8ncia reactiva de {entity_name}", "signal_strength": "Canvia la pot\u00e8ncia de senyal de {entity_name}", + "speed": "Canvia la velocitat de {entity_name}", "sulphur_dioxide": "Canvia la concentraci\u00f3 de di\u00f2xid de sofre de {entity_name}", "temperature": "Canvia la temperatura de {entity_name}", "value": "Canvia el valor de {entity_name}", "volatile_organic_compounds": "Canvia la concentraci\u00f3 de compostos org\u00e0nics vol\u00e0tils de {entity_name}", - "voltage": "Canvia el voltatge de {entity_name}" + "voltage": "Canvia el voltatge de {entity_name}", + "volume": "Canvia el volum de {entity_name}", + "water": "Canvia l'aigua de {entity_name}", + "weight": "Canvia el pes de {entity_name}" } }, "state": { diff --git a/homeassistant/components/sensor/translations/cs.json b/homeassistant/components/sensor/translations/cs.json index 23ba416dd341f021e8712faf0e8de8a56b408767..d8f6dd25b576ac544e3482046747b4cc873544ae 100644 --- a/homeassistant/components/sensor/translations/cs.json +++ b/homeassistant/components/sensor/translations/cs.json @@ -3,6 +3,7 @@ "condition_type": { "is_battery_level": "Aktu\u00e1ln\u00ed \u00farove\u0148 nabit\u00ed baterie {entity_name}", "is_current": "Aktu\u00e1ln\u00ed proud {entity_name}", + "is_distance": "Aktu\u00e1ln\u00ed vzd\u00e1lenost {entity_name}", "is_energy": "Aktu\u00e1ln\u00ed energie {entity_name}", "is_gas": "Aktu\u00e1ln\u00ed mno\u017estv\u00ed plynu {entity_name}", "is_humidity": "Aktu\u00e1ln\u00ed vlhkost {entity_name}", @@ -12,14 +13,17 @@ "is_power_factor": "Aktu\u00e1ln\u00ed \u00fa\u010din\u00edk {entity_name}", "is_pressure": "Aktu\u00e1ln\u00ed tlak {entity_name}", "is_signal_strength": "Aktu\u00e1ln\u00ed s\u00edla sign\u00e1lu {entity_name}", + "is_speed": "Aktu\u00e1ln\u00ed rychlost {entity_name}", "is_sulphur_dioxide": "Aktu\u00e1ln\u00ed \u00farove\u0148 koncentrace oxidu si\u0159i\u010dit\u00e9ho {entity_name}", "is_temperature": "Aktu\u00e1ln\u00ed teplota {entity_name}", "is_value": "Aktu\u00e1ln\u00ed hodnota {entity_name}", - "is_voltage": "Aktu\u00e1ln\u00ed nap\u011bt\u00ed {entity_name}" + "is_voltage": "Aktu\u00e1ln\u00ed nap\u011bt\u00ed {entity_name}", + "is_volume": "Aktu\u00e1ln\u00ed objem {entity_name}" }, "trigger_type": { "battery_level": "P\u0159i zm\u011bn\u011b \u00farovn\u011b baterie {entity_name}", "current": "P\u0159i zm\u011bn\u011b proudu {entity_name}", + "distance": "P\u0159i zm\u011bn\u011b vzd\u00e1lenosti {entity_name}", "energy": "P\u0159i zm\u011bn\u011b energie {entity_name}", "gas": "P\u0159i zm\u011bn\u011b mno\u017estv\u00ed plynu {entity_name}", "humidity": "P\u0159i zm\u011bn\u011b vlhkosti {entity_name}", @@ -30,9 +34,11 @@ "power_factor": "P\u0159i zm\u011bn\u011b \u00fa\u010din\u00edku {entity_name}", "pressure": "P\u0159i zm\u011bn\u011b tlaku {entity_name}", "signal_strength": "P\u0159i zm\u011bn\u011b s\u00edly sign\u00e1lu {entity_name}", + "speed": "P\u0159i zm\u011bn\u011b rychlosti {entity_name}", "temperature": "P\u0159i zm\u011bn\u011b teploty {entity_name}", "value": "P\u0159i zm\u011bn\u011b hodnoty {entity_name}", - "voltage": "P\u0159i zm\u011bn\u011b nap\u011bt\u00ed {entity_name}" + "voltage": "P\u0159i zm\u011bn\u011b nap\u011bt\u00ed {entity_name}", + "volume": "P\u0159i zm\u011bn\u011b objemu {entity_name}" } }, "state": { diff --git a/homeassistant/components/sensor/translations/de.json b/homeassistant/components/sensor/translations/de.json index b0cdbd198aa891af8780fa1d0294a7de534ecc15..3920f829bf4a8a47b8b989bd1c18ded2601ce7af 100644 --- a/homeassistant/components/sensor/translations/de.json +++ b/homeassistant/components/sensor/translations/de.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "Aktuelle {entity_name} Kohlenstoffdioxid-Konzentration", "is_carbon_monoxide": "Aktuelle {entity_name} Kohlenstoffmonoxid-Konzentration", "is_current": "Aktueller Strom von {entity_name}", + "is_distance": "Aktuelle Entfernung zu {entity_name}", "is_energy": "Aktuelle Energie von {entity_name}", "is_frequency": "Aktuelle {entity_name} Frequenz", "is_gas": "Aktuelles {entity_name} Gas", @@ -24,11 +25,15 @@ "is_pressure": "{entity_name} Druck", "is_reactive_power": "Aktuelle Blindleistung von {entity_name}", "is_signal_strength": "Aktuelle {entity_name} Signalst\u00e4rke", + "is_speed": "Aktuelle Geschwindigkeit von {entity_name}", "is_sulphur_dioxide": "Aktuelle Schwefeldioxid-Konzentration von {entity_name}", "is_temperature": "Aktuelle {entity_name} Temperatur", "is_value": "Aktueller {entity_name} Wert", "is_volatile_organic_compounds": "Aktuelle Konzentration fl\u00fcchtiger organischer Verbindungen in {entity_name}", - "is_voltage": "Aktuelle Spannung von {entity_name}" + "is_voltage": "Aktuelle Spannung von {entity_name}", + "is_volume": "Aktuelle Lautst\u00e4rke von {entity_name}", + "is_water": "Aktuelles {entity_name} Wasser", + "is_weight": "Aktuelles Gewicht von {entity_name}" }, "trigger_type": { "apparent_power": "{entity_name} \u00c4nderungen der Scheinleistung", @@ -36,6 +41,7 @@ "carbon_dioxide": "{entity_name} Kohlenstoffdioxid-Konzentrations\u00e4nderung", "carbon_monoxide": "{entity_name} Kohlenstoffmonoxid-Konzentrations\u00e4nderung", "current": "{entity_name} Stromver\u00e4nderung", + "distance": "Abstand zu {entity_name} \u00e4ndert sich", "energy": "{entity_name} Energie\u00e4nderungen", "frequency": "{entity_name} Frequenz\u00e4nderungen", "gas": "{entity_name} Gas\u00e4nderungen", @@ -54,11 +60,15 @@ "pressure": "{entity_name} Druck\u00e4nderungen", "reactive_power": "{entity_name} Blindleistung \u00e4ndert sich", "signal_strength": "{entity_name} Signalst\u00e4rke\u00e4nderungen", + "speed": "Geschwindigkeit von {entity_name} \u00e4ndert sich", "sulphur_dioxide": "\u00c4nderung der Schwefeldioxidkonzentration bei {entity_name}", "temperature": "{entity_name} Temperatur\u00e4nderungen", "value": "{entity_name} Wert\u00e4nderungen", "volatile_organic_compounds": "{entity_name} Konzentrations\u00e4nderungen fl\u00fcchtiger organischer Verbindungen", - "voltage": "{entity_name} Spannungs\u00e4nderungen" + "voltage": "{entity_name} Spannungs\u00e4nderungen", + "volume": "Lautst\u00e4rke von {entity_name} \u00e4ndert sich", + "water": "{entity_name} Wasser\u00e4nderung", + "weight": "Das Gewicht von {entity_name} \u00e4ndert sich" } }, "state": { diff --git a/homeassistant/components/sensor/translations/el.json b/homeassistant/components/sensor/translations/el.json index 543ef1c24ad288e47d966ccad77b4d61a4c1e029..6acf181aad358a3b3145bfde6c0b0ca76191280a 100644 --- a/homeassistant/components/sensor/translations/el.json +++ b/homeassistant/components/sensor/translations/el.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03ac\u03bd\u03b8\u03c1\u03b1\u03ba\u03b1 \u03c4\u03bf\u03c5 {entity_name}", "is_carbon_monoxide": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03bc\u03bf\u03bd\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03ac\u03bd\u03b8\u03c1\u03b1\u03ba\u03b1 \u03c4\u03bf\u03c5 {entity_name}", "is_current": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03c1\u03b5\u03cd\u03bc\u03b1 \u03b3\u03b9\u03b1 {entity_name}", + "is_distance": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03b1\u03c0\u03cc\u03c3\u03c4\u03b1\u03c3\u03b7 {entity_name}", "is_energy": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03b5\u03bd\u03ad\u03c1\u03b3\u03b5\u03b9\u03b1 {entity_name}", "is_frequency": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03c3\u03c5\u03c7\u03bd\u03cc\u03c4\u03b7\u03c4\u03b1 {entity_name}", "is_gas": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b1\u03ad\u03c1\u03b9\u03bf {entity_name}", @@ -24,11 +25,14 @@ "is_pressure": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03c0\u03af\u03b5\u03c3\u03b7 {entity_name}", "is_reactive_power": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03ac\u03b5\u03c1\u03b3\u03b7 \u03b9\u03c3\u03c7\u03cd\u03c2 {entity_name}", "is_signal_strength": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03b9\u03c3\u03c7\u03cd\u03c2 \u03c3\u03ae\u03bc\u03b1\u03c4\u03bf\u03c2 {entity_name}", + "is_speed": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03c4\u03b1\u03c7\u03cd\u03c4\u03b7\u03c4\u03b1 {entity_name}", "is_sulphur_dioxide": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b8\u03b5\u03af\u03bf\u03c5 {entity_name}", "is_temperature": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03b8\u03b5\u03c1\u03bc\u03bf\u03ba\u03c1\u03b1\u03c3\u03af\u03b1 {entity_name}", "is_value": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03c4\u03b9\u03bc\u03ae \u03c4\u03bf\u03c5 {entity_name}", "is_volatile_organic_compounds": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b5\u03c0\u03af\u03c0\u03b5\u03b4\u03bf \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03c0\u03c4\u03b7\u03c4\u03b9\u03ba\u03ce\u03bd \u03bf\u03c1\u03b3\u03b1\u03bd\u03b9\u03ba\u03ce\u03bd \u03b5\u03bd\u03ce\u03c3\u03b5\u03c9\u03bd {entity_name}", - "is_voltage": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03c4\u03ac\u03c3\u03b7 {entity_name}" + "is_voltage": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03c4\u03ac\u03c3\u03b7 {entity_name}", + "is_volume": "\u03a4\u03c1\u03ad\u03c7\u03c9\u03bd \u03cc\u03b3\u03ba\u03bf\u03c2 {entity_name}", + "is_weight": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03bd \u03b2\u03ac\u03c1\u03bf\u03c2 {entity_name}" }, "trigger_type": { "apparent_power": "\u0395\u03bc\u03c6\u03b1\u03bd\u03b5\u03af\u03c2 \u03b1\u03bb\u03bb\u03b1\u03b3\u03ad\u03c2 \u03b9\u03c3\u03c7\u03cd\u03bf\u03c2 {entity_name}", @@ -36,6 +40,7 @@ "carbon_dioxide": "\u0397 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03ac\u03bd\u03b8\u03c1\u03b1\u03ba\u03b1 \u03c4\u03bf\u03c5 {entity_name} \u03b1\u03bb\u03bb\u03ac\u03b6\u03b5\u03b9", "carbon_monoxide": "\u0397 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03bc\u03bf\u03bd\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03ac\u03bd\u03b8\u03c1\u03b1\u03ba\u03b1 \u03c4\u03bf\u03c5 {entity_name} \u03b1\u03bb\u03bb\u03ac\u03b6\u03b5\u03b9", "current": "{entity_name} \u03c4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b5\u03c2 \u03b1\u03bb\u03bb\u03b1\u03b3\u03ad\u03c2", + "distance": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ae \u03b1\u03c0\u03cc\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2 {entity_name}", "energy": "\u0397 \u03b5\u03bd\u03ad\u03c1\u03b3\u03b5\u03b9\u03b1 \u03c4\u03bf\u03c5 {entity_name} \u03b1\u03bb\u03bb\u03ac\u03b6\u03b5\u03b9", "frequency": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ad\u03c2 \u03c3\u03c5\u03c7\u03bd\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 {entity_name}", "gas": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03b1\u03b5\u03c1\u03af\u03bf\u03c5", @@ -54,11 +59,14 @@ "pressure": "\u0397 \u03c0\u03af\u03b5\u03c3\u03b7 \u03c4\u03bf\u03c5 {entity_name} \u03b1\u03bb\u03bb\u03ac\u03b6\u03b5\u03b9", "reactive_power": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ad\u03c2 \u03b1\u03ad\u03c1\u03b3\u03bf\u03c5 \u03b9\u03c3\u03c7\u03cd\u03bf\u03c2 {entity_name}", "signal_strength": "\u0397 \u03b9\u03c3\u03c7\u03cd\u03c2 \u03c4\u03bf\u03c5 \u03c3\u03ae\u03bc\u03b1\u03c4\u03bf\u03c2 \u03c4\u03bf\u03c5 {entity_name} \u03b1\u03bb\u03bb\u03ac\u03b6\u03b5\u03b9", + "speed": "\u0397 \u03c4\u03b1\u03c7\u03cd\u03c4\u03b7\u03c4\u03b1 {entity_name} \u03b1\u03bb\u03bb\u03ac\u03b6\u03b5\u03b9", "sulphur_dioxide": "{entity_name} \u03bc\u03b5\u03c4\u03b1\u03b2\u03bf\u03bb\u03ad\u03c2 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7\u03c2 \u03b4\u03b9\u03bf\u03be\u03b5\u03b9\u03b4\u03af\u03bf\u03c5 \u03c4\u03bf\u03c5 \u03b8\u03b5\u03af\u03bf\u03c5", "temperature": "\u0397 \u03b8\u03b5\u03c1\u03bc\u03bf\u03ba\u03c1\u03b1\u03c3\u03af\u03b1 \u03c4\u03bf\u03c5 {entity_name} \u03b1\u03bb\u03bb\u03ac\u03b6\u03b5\u03b9", "value": "\u0397 \u03c4\u03b9\u03bc\u03ae \u03c4\u03bf\u03c5 {entity_name} \u03b1\u03bb\u03bb\u03ac\u03b6\u03b5\u03b9", "volatile_organic_compounds": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ad\u03c2 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03b3\u03ba\u03ad\u03bd\u03c4\u03c1\u03c9\u03c3\u03b7 \u03c4\u03c9\u03bd \u03c0\u03c4\u03b7\u03c4\u03b9\u03ba\u03ce\u03bd \u03bf\u03c1\u03b3\u03b1\u03bd\u03b9\u03ba\u03ce\u03bd \u03b5\u03bd\u03ce\u03c3\u03b5\u03c9\u03bd {entity_name}", - "voltage": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ae \u03c4\u03ac\u03c3\u03b7\u03c2 {entity_name}" + "voltage": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ae \u03c4\u03ac\u03c3\u03b7\u03c2 {entity_name}", + "volume": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ae \u03cc\u03b3\u03ba\u03bf\u03c5 {entity_name}", + "weight": "\u03a4\u03bf \u03b2\u03ac\u03c1\u03bf\u03c2 {entity_name} \u03b1\u03bb\u03bb\u03ac\u03b6\u03b5\u03b9" } }, "state": { diff --git a/homeassistant/components/sensor/translations/en.json b/homeassistant/components/sensor/translations/en.json index 1eeb31aa15c366f402d6040306e969fbc616167d..7fbb6e4a336442323bfb26a971b6426a00a0ddb1 100644 --- a/homeassistant/components/sensor/translations/en.json +++ b/homeassistant/components/sensor/translations/en.json @@ -32,6 +32,7 @@ "is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level", "is_voltage": "Current {entity_name} voltage", "is_volume": "Current {entity_name} volume", + "is_water": "Current {entity_name} water", "is_weight": "Current {entity_name} weight" }, "trigger_type": { @@ -66,6 +67,7 @@ "volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes", "voltage": "{entity_name} voltage changes", "volume": "{entity_name} volume changes", + "water": "{entity_name} water changes", "weight": "{entity_name} weight changes" } }, diff --git a/homeassistant/components/sensor/translations/es.json b/homeassistant/components/sensor/translations/es.json index d005742fee428b63f24174bbe70fb46c8b82bdf3..7914f0ff0d35a6bb7e921c90c021cba9101c2fb0 100644 --- a/homeassistant/components/sensor/translations/es.json +++ b/homeassistant/components/sensor/translations/es.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "El nivel de la concentraci\u00f3n de di\u00f3xido de carbono actual de {entity_name}", "is_carbon_monoxide": "El nivel de la concentraci\u00f3n de mon\u00f3xido de carbono actual de {entity_name}", "is_current": "La intensidad de corriente actual de {entity_name}", + "is_distance": "Distancia actual de {entity_name}", "is_energy": "La energ\u00eda actual de {entity_name}", "is_frequency": "La frecuencia actual de {entity_name}", "is_gas": "El gas actual de {entity_name}", @@ -24,11 +25,15 @@ "is_pressure": "La presi\u00f3n actual de {entity_name}", "is_reactive_power": "La potencia reactiva actual de {entity_name}", "is_signal_strength": "La intensidad de la se\u00f1al actual de {entity_name}", + "is_speed": "Velocidad actual de {entity_name}", "is_sulphur_dioxide": "El nivel de la concentraci\u00f3n de di\u00f3xido de azufre actual de {entity_name}", "is_temperature": "La temperatura actual de {entity_name}", "is_value": "El valor actual de {entity_name}", "is_volatile_organic_compounds": "El nivel de la concentraci\u00f3n de compuestos org\u00e1nicos vol\u00e1tiles actual de {entity_name}", - "is_voltage": "El voltaje actual de {entity_name}" + "is_voltage": "El voltaje actual de {entity_name}", + "is_volume": "Volumen actual de {entity_name}", + "is_water": "Agua actual de {entity_name}", + "is_weight": "El peso actual de {entity_name}" }, "trigger_type": { "apparent_power": "La potencia aparente de {entity_name} cambia", @@ -36,6 +41,7 @@ "carbon_dioxide": "La concentraci\u00f3n de di\u00f3xido de carbono de {entity_name} cambia", "carbon_monoxide": "La concentraci\u00f3n de mon\u00f3xido de carbono de {entity_name} cambia", "current": "La intensidad de corriente de {entity_name} cambia", + "distance": "La distancia de {entity_name} cambia", "energy": "La energ\u00eda de {entity_name} cambia", "frequency": "La frecuencia de {entity_name} cambia", "gas": "El gas de {entity_name} cambia", @@ -54,11 +60,15 @@ "pressure": "La presi\u00f3n de {entity_name} cambia", "reactive_power": "La potencia reactiva de {entity_name} cambia", "signal_strength": "La intensidad de se\u00f1al de {entity_name} cambia", + "speed": "La velocidad de {entity_name} cambia", "sulphur_dioxide": "La concentraci\u00f3n de di\u00f3xido de azufre de {entity_name} cambia", "temperature": "La temperatura de {entity_name} cambia", "value": "El valor de {entity_name} cambia", "volatile_organic_compounds": "La concentraci\u00f3n de compuestos org\u00e1nicos vol\u00e1tiles de {entity_name} cambia", - "voltage": "El voltaje de {entity_name} cambia" + "voltage": "El voltaje de {entity_name} cambia", + "volume": "El volumen de {entity_name} cambia", + "water": "El agua de {entity_name} cambia", + "weight": "El peso de {entity_name} cambia" } }, "state": { diff --git a/homeassistant/components/sensor/translations/et.json b/homeassistant/components/sensor/translations/et.json index bbc6880dceea02db0d73566c78e9de72004c98e4..cbc760ea929870c46c47d7452e079d9a2e28401b 100644 --- a/homeassistant/components/sensor/translations/et.json +++ b/homeassistant/components/sensor/translations/et.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "{entity_name} praegune s\u00fcsihappegaasi tase", "is_carbon_monoxide": "{entity_name} praegune vingugaasi tase", "is_current": "Praegune {entity_name} voolutugevus", + "is_distance": "Praegune {entity_name} kaugus", "is_energy": "Praegune {entity_name} v\u00f5imsus", "is_frequency": "Praegune {entity_name} sagedus", "is_gas": "Praegune {entity_name} gaas", @@ -24,11 +25,15 @@ "is_pressure": "Praegune {entity_name} r\u00f5hk", "is_reactive_power": "Praegune {entity_name} reaktiivv\u00f5imsus", "is_signal_strength": "Praegune {entity_name} signaali tugevus", + "is_speed": "Praegune {entity_name} kiirus", "is_sulphur_dioxide": "Praegune v\u00e4\u00e4veldioksiidi kontsentratsioonitase {entity_name}", "is_temperature": "Praegune {entity_name} temperatuur", "is_value": "Praegune {entity_name} v\u00e4\u00e4rtus", "is_volatile_organic_compounds": "Praegune {entity_name} lenduvate orgaaniliste \u00fchendite kontsentratsioonitase", - "is_voltage": "Praegune {entity_name}pinge" + "is_voltage": "Praegune {entity_name}pinge", + "is_volume": "Praegune {entity_name} helitugevus", + "is_water": "Praegune {entity_name} veekulu", + "is_weight": "Praegune {entity_name} kaal" }, "trigger_type": { "apparent_power": "{entity_name} n\u00e4iv v\u00f5imsus muutub", @@ -36,6 +41,7 @@ "carbon_dioxide": "{entity_name} s\u00fcsihappegaasi tase muutus", "carbon_monoxide": "{entity_name} vingugaasi tase muutus", "current": "{entity_name} voolutugevus muutub", + "distance": "{entity_name} kaugus muutub", "energy": "{entity_name} v\u00f5imsus muutub", "frequency": "{entity_name} sagedus muutub", "gas": "{entity_name} gaasivahetus", @@ -54,11 +60,15 @@ "pressure": "{entity_name} r\u00f5hk muutub", "reactive_power": "{entity_name} reaktiivv\u00f5imsus muutub", "signal_strength": "{entity_name} signaalitugevus muutub", + "speed": "{entity_name} kiirus muutub", "sulphur_dioxide": "{entity_name} v\u00e4\u00e4veldioksiidi kontsentratsiooni muutused", "temperature": "{entity_name} temperatuur muutub", "value": "{entity_name} v\u00e4\u00e4rtus muutub", "volatile_organic_compounds": "{entity_name} lenduvate orgaaniliste \u00fchendite kontsentratsiooni muutused", - "voltage": "{entity_name} pingemuutub" + "voltage": "{entity_name} pingemuutub", + "volume": "{entity_name} helitugevus muutub", + "water": "{entity_name} veekulu muutub", + "weight": "{entity_name} kaal muutus" } }, "state": { diff --git a/homeassistant/components/sensor/translations/he.json b/homeassistant/components/sensor/translations/he.json index fc0ba9b48c45088748ae2487f9f6d1eb05b1afb6..6adc9a6da878be88d5b1b57062f00d060733ff77 100644 --- a/homeassistant/components/sensor/translations/he.json +++ b/homeassistant/components/sensor/translations/he.json @@ -4,23 +4,45 @@ "is_apparent_power": "\u05d4\u05e2\u05d5\u05e6\u05de\u05d4 \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05ea {entity_name} \u05de\u05e1\u05ea\u05de\u05e0\u05ea", "is_battery_level": "\u05e8\u05de\u05ea \u05d4\u05e1\u05d5\u05dc\u05dc\u05d4 \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05ea \u05e9\u05dc {entity_name}", "is_current": "\u05db\u05e2\u05ea {entity_name}", + "is_distance": "\u05de\u05e8\u05d7\u05e7 \u05e0\u05d5\u05db\u05d7\u05d9 {entity_name}", "is_energy": "\u05d0\u05e0\u05e8\u05d2\u05d9\u05d4 \u05e0\u05d5\u05db\u05d7\u05d9\u05ea {entity_name}", + "is_frequency": "\u05ea\u05d3\u05e8 \u05e0\u05d5\u05db\u05d7\u05d9 {entity_name}", "is_gas": "\u05db\u05e2\u05ea {entity_name} \u05d2\u05d6", + "is_humidity": "\u05dc\u05d7\u05d5\u05ea \u05e0\u05d5\u05db\u05d7\u05d9\u05ea {entity_name}", "is_illuminance": "\u05e2\u05d5\u05e6\u05de\u05ea \u05d4\u05d0\u05e8\u05d4 {entity_name} \u05e0\u05d5\u05db\u05d7\u05d9\u05ea", + "is_moisture": "\u05d4\u05dc\u05d7\u05d5\u05ea \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05ea {entity_name}", + "is_nitrogen_dioxide": "\u05e8\u05de\u05ea \u05e8\u05d9\u05db\u05d5\u05d6 \u05d4\u05d7\u05e0\u05e7\u05df \u05d4\u05d3\u05d5-\u05d7\u05de\u05e6\u05e0\u05d9 \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05ea {entity_name}", + "is_nitrogen_monoxide": "\u05e8\u05de\u05ea \u05e8\u05d9\u05db\u05d5\u05d6 \u05d4\u05d7\u05e0\u05e7\u05df \u05d7\u05d3 \u05d7\u05de\u05e6\u05e0\u05d9 {entity_name} \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05ea", + "is_nitrous_oxide": "\u05e8\u05de\u05ea \u05e8\u05d9\u05db\u05d5\u05d6 \u05ea\u05d7\u05de\u05d5\u05e6\u05ea \u05d4\u05d7\u05e0\u05e7\u05df \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05ea {entity_name}", + "is_ozone": "\u05e8\u05de\u05ea \u05e8\u05d9\u05db\u05d5\u05d6 \u05d4\u05d0\u05d5\u05d6\u05d5\u05df \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05ea {entity_name}", "is_pm1": "\u05e8\u05de\u05ea \u05d4\u05e8\u05d9\u05db\u05d5\u05d6 \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05ea {entity_name} PM1", + "is_pm10": "\u05e8\u05de\u05ea \u05e8\u05d9\u05db\u05d5\u05d6 \u05d6\u05e8\u05dd {entity_name} PM10", + "is_pm25": "\u05e8\u05de\u05ea \u05e8\u05d9\u05db\u05d5\u05d6 \u05d6\u05e8\u05dd {entity_name} PM2.5", + "is_power": "\u05db\u05d5\u05d7 \u05e0\u05d5\u05db\u05d7\u05d9 {entity_name}", + "is_pressure": "\u05dc\u05d7\u05e5 \u05e0\u05d5\u05db\u05d7\u05d9 {entity_name}", "is_reactive_power": "\u05d4\u05e1\u05e4\u05e7 \u05ea\u05d2\u05d5\u05d1\u05ea\u05d9 \u05e0\u05d5\u05db\u05d7\u05d9 {entity_name}", - "is_temperature": "\u05db\u05e2\u05ea {entity_name} \u05d8\u05de\u05e4\u05e8\u05d8\u05d5\u05e8\u05d4" + "is_signal_strength": "\u05e2\u05d5\u05e6\u05de\u05ea \u05d4\u05d0\u05d5\u05ea \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05ea {entity_name}", + "is_speed": "\u05de\u05d4\u05d9\u05e8\u05d5\u05ea \u05e0\u05d5\u05db\u05d7\u05d9\u05ea {entity_name}", + "is_sulphur_dioxide": "\u05e8\u05de\u05ea \u05e8\u05d9\u05db\u05d5\u05d6 \u05d4\u05d2\u05d5\u05e4\u05e8\u05d9\u05ea \u05d4\u05d3\u05d5-\u05d7\u05de\u05e6\u05e0\u05d9\u05ea \u05e9\u05dc \u05d6\u05e8\u05dd {entity_name}", + "is_temperature": "\u05db\u05e2\u05ea {entity_name} \u05d8\u05de\u05e4\u05e8\u05d8\u05d5\u05e8\u05d4", + "is_value": "\u05e2\u05e8\u05da \u05e0\u05d5\u05db\u05d7\u05d9 {entity_name}", + "is_volatile_organic_compounds": "\u05e8\u05de\u05ea \u05e8\u05d9\u05db\u05d5\u05d6 \u05d4\u05ea\u05e8\u05db\u05d5\u05d1\u05d5\u05ea \u05d4\u05d0\u05d5\u05e8\u05d2\u05e0\u05d9\u05d5\u05ea \u05d4\u05e0\u05d3\u05d9\u05e4\u05d5\u05ea \u05d4\u05e0\u05d5\u05db\u05d7\u05d9\u05d5\u05ea {entity_name}", + "is_volume": "\u05e0\u05e4\u05d7 \u05e0\u05d5\u05db\u05d7\u05d9 \u05e9\u05dc {entity_name}", + "is_weight": "\u05de\u05e9\u05e7\u05dc \u05e0\u05d5\u05db\u05d7\u05d9 {entity_name}" }, "trigger_type": { "apparent_power": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05d4\u05e1\u05e4\u05e7 \u05dc\u05db\u05d0\u05d5\u05e8\u05d4", "battery_level": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05d1\u05e8\u05de\u05ea \u05d4\u05e1\u05d5\u05dc\u05dc\u05d4", "current": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05e0\u05d5\u05db\u05d7\u05d9\u05d9\u05dd", + "distance": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05de\u05e8\u05d7\u05e7", "energy": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05d0\u05e0\u05e8\u05d2\u05d9\u05d4", "frequency": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05ea\u05d3\u05e8\u05d9\u05dd", "gas": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05d2\u05d6", "humidity": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05dc\u05d7\u05d5\u05ea", "illuminance": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05e2\u05d5\u05e6\u05de\u05ea \u05d4\u05d0\u05e8\u05d4", "nitrogen_dioxide": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05d1\u05e8\u05d9\u05db\u05d5\u05d6 \u05d4\u05d7\u05e0\u05e7\u05df \u05d4\u05d3\u05d5-\u05d7\u05de\u05e6\u05e0\u05d9", + "nitrogen_monoxide": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05d1\u05e8\u05d9\u05db\u05d5\u05d6 \u05d7\u05d3 \u05ea\u05d7\u05de\u05d5\u05e6\u05ea \u05d4\u05d7\u05e0\u05e7\u05df", + "nitrous_oxide": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05d1\u05e8\u05d9\u05db\u05d5\u05d6 \u05ea\u05d7\u05de\u05d5\u05e6\u05ea \u05d4\u05d7\u05e0\u05e7\u05df", "ozone": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05d1\u05e8\u05d9\u05db\u05d5\u05d6 \u05d4\u05d0\u05d5\u05d6\u05d5\u05df", "pm1": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05d1\u05e8\u05d9\u05db\u05d5\u05d6 PM1", "pm10": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05d1\u05e8\u05d9\u05db\u05d5\u05d6 PM10", @@ -29,9 +51,14 @@ "power_factor": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05d1\u05d2\u05d5\u05e8\u05dd \u05d4\u05d4\u05e1\u05e4\u05e7", "pressure": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05dc\u05d7\u05e5", "reactive_power": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05d4\u05e1\u05e4\u05e7 \u05ea\u05d2\u05d5\u05d1\u05ea\u05d9", + "signal_strength": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05d1\u05e2\u05d5\u05e6\u05de\u05ea \u05d4\u05d0\u05d5\u05ea", + "speed": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05de\u05d4\u05d9\u05e8\u05d5\u05ea", + "sulphur_dioxide": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05d1\u05e8\u05d9\u05db\u05d5\u05d6 \u05d3\u05d5 \u05ea\u05d7\u05de\u05d5\u05e6\u05ea \u05d4\u05d2\u05d5\u05e4\u05e8\u05d9\u05ea", "temperature": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05d8\u05de\u05e4\u05e8\u05d8\u05d5\u05e8\u05d4", "value": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05e2\u05e8\u05da", - "voltage": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05de\u05ea\u05d7" + "voltage": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05de\u05ea\u05d7", + "volume": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9 \u05e0\u05e4\u05d7", + "weight": "{entity_name} \u05e9\u05d9\u05e0\u05d5\u05d9\u05d9\u05dd \u05d1\u05de\u05e9\u05e7\u05dc" } }, "state": { diff --git a/homeassistant/components/sensor/translations/hu.json b/homeassistant/components/sensor/translations/hu.json index 48fe4a651b89256f0cfcaef998f3093d9c4dcd93..4a124c4402bba69dc00a3db6c61762a2d54dff04 100644 --- a/homeassistant/components/sensor/translations/hu.json +++ b/homeassistant/components/sensor/translations/hu.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "Jelenlegi {entity_name} sz\u00e9n-dioxid koncentr\u00e1ci\u00f3 szint", "is_carbon_monoxide": "Jelenlegi {entity_name} sz\u00e9n-monoxid koncentr\u00e1ci\u00f3 szint", "is_current": "Jelenlegi {entity_name} \u00e1ram", + "is_distance": "{entity_name} aktu\u00e1lis t\u00e1vols\u00e1ga", "is_energy": "A jelenlegi {entity_name} energia", "is_frequency": "Aktu\u00e1lis {entity_name} gyakoris\u00e1g", "is_gas": "Jelenlegi {entity_name} g\u00e1z", @@ -24,11 +25,15 @@ "is_pressure": "{entity_name} aktu\u00e1lis nyom\u00e1sa", "is_reactive_power": "Aktu\u00e1lis {entity_name} reakt\u00edv teljes\u00edtm\u00e9ny", "is_signal_strength": "{entity_name} aktu\u00e1lis jeler\u0151ss\u00e9ge", + "is_speed": "{entity_name} aktu\u00e1lis sebess\u00e9ge", "is_sulphur_dioxide": "A {entity_name} k\u00e9n-dioxid koncentr\u00e1ci\u00f3 jelenlegi szintje", "is_temperature": "{entity_name} aktu\u00e1lis h\u0151m\u00e9rs\u00e9klete", "is_value": "{entity_name} aktu\u00e1lis \u00e9rt\u00e9ke", "is_volatile_organic_compounds": "Jelenlegi {entity_name} ill\u00e9kony szerves vegy\u00fcletek koncentr\u00e1ci\u00f3s szintje", - "is_voltage": "A jelenlegi {entity_name} fesz\u00fclts\u00e9g" + "is_voltage": "A jelenlegi {entity_name} fesz\u00fclts\u00e9g", + "is_volume": "{entity_name} aktu\u00e1lis hangereje", + "is_water": "Aktu\u00e1lis {entity_name} v\u00edz", + "is_weight": "{entity_name} aktu\u00e1lis s\u00falya" }, "trigger_type": { "apparent_power": "{entity_name} l\u00e1tsz\u00f3lagos teljes\u00edtm\u00e9ny v\u00e1ltoz\u00e1sok", @@ -36,6 +41,7 @@ "carbon_dioxide": "{entity_name} sz\u00e9n-dioxid koncentr\u00e1ci\u00f3ja megv\u00e1ltozik", "carbon_monoxide": "{entity_name} sz\u00e9n-monoxid koncentr\u00e1ci\u00f3ja megv\u00e1ltozik", "current": "{entity_name} aktu\u00e1lis v\u00e1ltoz\u00e1sai", + "distance": "{entity_name} t\u00e1vols\u00e1g v\u00e1ltoz\u00e1s", "energy": "{entity_name} energiav\u00e1ltoz\u00e1sa", "frequency": "{entity_name} gyakoris\u00e1gi v\u00e1ltoz\u00e1sok", "gas": "{entity_name} g\u00e1z v\u00e1ltoz\u00e1sok", @@ -54,11 +60,15 @@ "pressure": "{entity_name} nyom\u00e1sa v\u00e1ltozik", "reactive_power": "{entity_name} reakt\u00edv teljes\u00edtm\u00e9ny v\u00e1ltoz\u00e1sok", "signal_strength": "{entity_name} jeler\u0151ss\u00e9ge v\u00e1ltozik", + "speed": "{entity_name} sebess\u00e9gv\u00e1ltoz\u00e1s", "sulphur_dioxide": "{entity_name} k\u00e9n-dioxid koncentr\u00e1ci\u00f3v\u00e1ltoz\u00e1s", "temperature": "{entity_name} h\u0151m\u00e9rs\u00e9klete v\u00e1ltozik", "value": "{entity_name} \u00e9rt\u00e9ke v\u00e1ltozik", "volatile_organic_compounds": "{entity_name} ill\u00e9kony szerves vegy\u00fcletek koncentr\u00e1ci\u00f3j\u00e1nak v\u00e1ltoz\u00e1sai", - "voltage": "{entity_name} fesz\u00fclts\u00e9ge v\u00e1ltozik" + "voltage": "{entity_name} fesz\u00fclts\u00e9ge v\u00e1ltozik", + "volume": "{entity_name} hanger\u0151 v\u00e1ltoz\u00e1s", + "water": "{entity_name} v\u00edz v\u00e1ltoz\u00e1sok", + "weight": "{entity_name} s\u00falyv\u00e1ltoz\u00e1s" } }, "state": { diff --git a/homeassistant/components/sensor/translations/id.json b/homeassistant/components/sensor/translations/id.json index 81f1126591d93f0fcfd12742a75314c490bb5e5b..e9e2340fed0d2bb791b6b49fe15d9f8004996ffe 100644 --- a/homeassistant/components/sensor/translations/id.json +++ b/homeassistant/components/sensor/translations/id.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "Level konsentasi karbondioksida {entity_name} saat ini", "is_carbon_monoxide": "Level konsentasi karbonmonoksida {entity_name} saat ini", "is_current": "Arus {entity_name} saat ini", + "is_distance": "Jarak {entity_name} saat ini", "is_energy": "Energi {entity_name} saat ini", "is_frequency": "Frekuensi {entity_name} saat ini", "is_gas": "Gas {entity_name} saat ini", @@ -24,11 +25,14 @@ "is_pressure": "Tekanan {entity_name} saat ini", "is_reactive_power": "Daya reaktif {entity_name}", "is_signal_strength": "Kekuatan sinyal {entity_name} saat ini", + "is_speed": "Kecepatan {entity_name} saat ini", "is_sulphur_dioxide": "Tingkat konsentrasi sulfur dioksida {entity_name} saat ini", "is_temperature": "Suhu {entity_name} saat ini", "is_value": "Nilai {entity_name} saat ini", "is_volatile_organic_compounds": "Tingkat konsentrasi senyawa organik volatil {entity_name} saat ini", - "is_voltage": "Tegangan {entity_name} saat ini" + "is_voltage": "Tegangan {entity_name} saat ini", + "is_volume": "Volume {entity_name} saat ini", + "is_weight": "Berat {entity_name} saat ini" }, "trigger_type": { "apparent_power": "Perubahan daya nyata {entity_name}", @@ -36,6 +40,7 @@ "carbon_dioxide": "Perubahan konsentrasi karbondioksida {entity_name}", "carbon_monoxide": "Perubahan konsentrasi karbonmonoksida {entity_name}", "current": "Perubahan arus {entity_name}", + "distance": "Perubahan jarak {entity_name}", "energy": "Perubahan energi {entity_name}", "frequency": "Perubahan frekuensi {entity_name}", "gas": "Perubahan gas {entity_name}", @@ -54,11 +59,14 @@ "pressure": "Perubahan tekanan {entity_name}", "reactive_power": "Perubahan daya reaktif {entity_name}", "signal_strength": "Perubahan kekuatan sinyal {entity_name}", + "speed": "Perubahan kecepatan {entity_name}", "sulphur_dioxide": "Perubahan konsentrasi sulfur dioksida {entity_name}", "temperature": "Perubahan suhu {entity_name}", "value": "Perubahan nilai {entity_name}", "volatile_organic_compounds": "Perubahan konsentrasi senyawa organik volatil {entity_name}", - "voltage": "Perubahan tegangan {entity_name}" + "voltage": "Perubahan tegangan {entity_name}", + "volume": "Perubahan volume {entity_name}", + "weight": "Perubahan berat {entity_name}" } }, "state": { diff --git a/homeassistant/components/sensor/translations/it.json b/homeassistant/components/sensor/translations/it.json index caaddb8c858a5760e3a1b356021fa7ca44c13dde..ca319a3437a16ca5c4d896dfb2328480140efa3d 100644 --- a/homeassistant/components/sensor/translations/it.json +++ b/homeassistant/components/sensor/translations/it.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "Livello di concentrazione di anidride carbonica attuale in {entity_name}", "is_carbon_monoxide": "Livello attuale di concentrazione di monossido di carbonio in {entity_name}", "is_current": "Corrente attuale di {entity_name}", + "is_distance": "Distanza attuale di {entity_name}", "is_energy": "Energia attuale di {entity_name}", "is_frequency": "Frequenza attuale di {entity_name}", "is_gas": "Attuale gas di {entity_name}", @@ -24,11 +25,14 @@ "is_pressure": "Pressione attuale di {entity_name}", "is_reactive_power": "Potenza reattiva attuale di {entity_name}", "is_signal_strength": "Potenza del segnale attuale di {entity_name}", + "is_speed": "Velocit\u00e0 corrente di {entity_name}", "is_sulphur_dioxide": "Attuale livello di concentrazione di anidride solforosa di {entity_name}", "is_temperature": "Temperatura attuale di {entity_name}", "is_value": "Valore attuale di {entity_name}", "is_volatile_organic_compounds": "Attuale livello di concentrazione di composti organici volatili di {entity_name}", - "is_voltage": "Tensione attuale di {entity_name}" + "is_voltage": "Tensione attuale di {entity_name}", + "is_volume": "Volume attuale di {entity_name}", + "is_weight": "Peso attuale di {entity_name}" }, "trigger_type": { "apparent_power": "Variazioni di potenza apparente di {entity_name}", @@ -36,6 +40,7 @@ "carbon_dioxide": "Variazioni della concentrazione di anidride carbonica di {entity_name}", "carbon_monoxide": "Variazioni nella concentrazione di monossido di carbonio di {entity_name}", "current": "Variazioni di corrente di {entity_name}", + "distance": "Variazioni di distanza di {entity_name}", "energy": "Variazioni di energia di {entity_name}", "frequency": "{entity_name} cambiamenti di frequenza", "gas": "Variazioni di gas di {entity_name}", @@ -54,11 +59,14 @@ "pressure": "Variazioni della pressione di {entity_name}", "reactive_power": "Variazioni di potenza reattiva di {entity_name}", "signal_strength": "Variazioni della potenza del segnale di {entity_name}", + "speed": "Variazioni di velocit\u00e0 di {entity_name}", "sulphur_dioxide": "Variazioni della concentrazione di anidride solforosa di {entity_name}", "temperature": "Variazioni di temperatura di {entity_name}", "value": "Cambi di valore di {entity_name}", "volatile_organic_compounds": "Variazioni della concentrazione di composti organici volatili di {entity_name}", - "voltage": "variazioni di tensione di {entity_name}" + "voltage": "variazioni di tensione di {entity_name}", + "volume": "Variazioni di volume di {entity_name}", + "weight": "Variazioni di peso di {entity_name}" } }, "state": { diff --git a/homeassistant/components/sensor/translations/ja.json b/homeassistant/components/sensor/translations/ja.json index b7153e4b5de2f2453f40f37f680675f20058b420..568a53657aa15cc84b97243430d09a99e065fa2e 100644 --- a/homeassistant/components/sensor/translations/ja.json +++ b/homeassistant/components/sensor/translations/ja.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "\u73fe\u5728\u306e {entity_name} \u4e8c\u9178\u5316\u70ad\u7d20\u6fc3\u5ea6\u30ec\u30d9\u30eb", "is_carbon_monoxide": "\u73fe\u5728\u306e {entity_name} \u4e00\u9178\u5316\u70ad\u7d20\u6fc3\u5ea6\u30ec\u30d9\u30eb", "is_current": "\u73fe\u5728\u306e {entity_name} \u96fb\u6d41", + "is_distance": "\u73fe\u5728\u306e {entity_name} \u306e\u8ddd\u96e2", "is_energy": "\u73fe\u5728\u306e {entity_name} \u30a8\u30cd\u30eb\u30ae\u30fc", "is_frequency": "\u73fe\u5728\u306e {entity_name} \u983b\u5ea6(frequency)", "is_gas": "\u73fe\u5728\u306e {entity_name} \u30ac\u30b9", @@ -24,6 +25,7 @@ "is_pressure": "\u73fe\u5728\u306e {entity_name} \u5727\u529b", "is_reactive_power": "\u73fe\u5728\u306e{entity_name}\u7121\u52b9\u96fb\u529b", "is_signal_strength": "\u73fe\u5728\u306e {entity_name} \u4fe1\u53f7\u5f37\u5ea6", + "is_speed": "\u73fe\u5728\u306e {entity_name} \u306e\u901f\u5ea6", "is_sulphur_dioxide": "\u73fe\u5728\u306e {entity_name} \u4e8c\u9178\u5316\u786b\u9ec4\u6fc3\u5ea6\u30ec\u30d9\u30eb", "is_temperature": "\u73fe\u5728\u306e {entity_name} \u6e29\u5ea6", "is_value": "\u73fe\u5728\u306e {entity_name} \u5024", @@ -36,6 +38,7 @@ "carbon_dioxide": "{entity_name} \u4e8c\u9178\u5316\u70ad\u7d20\u6fc3\u5ea6\u306e\u5909\u5316", "carbon_monoxide": "{entity_name} \u4e00\u9178\u5316\u70ad\u7d20\u6fc3\u5ea6\u306e\u5909\u5316", "current": "{entity_name} \u73fe\u5728\u306e\u5909\u5316", + "distance": "{entity_name} \u306e\u8ddd\u96e2\u304c\u5909\u5316", "energy": "{entity_name} \u30a8\u30cd\u30eb\u30ae\u30fc\u306e\u5909\u5316", "frequency": "{entity_name} \u983b\u5ea6(frequency)\u304c\u5909\u5316", "gas": "{entity_name} \u30ac\u30b9\u306e\u5909\u5316", @@ -54,6 +57,7 @@ "pressure": "{entity_name} \u5727\u529b\u306e\u5909\u5316", "reactive_power": "{entity_name}\u7121\u52b9\u96fb\u529b\u306e\u5909\u66f4", "signal_strength": "{entity_name} \u4fe1\u53f7\u5f37\u5ea6\u306e\u5909\u5316", + "speed": "{entity_name} \u306e\u901f\u5ea6\u304c\u5909\u5316", "sulphur_dioxide": "{entity_name} \u4e8c\u9178\u5316\u786b\u9ec4\u6fc3\u5ea6\u306e\u5909\u5316", "temperature": "{entity_name} \u6e29\u5ea6\u5909\u5316", "value": "{entity_name} \u5024\u306e\u5909\u5316", diff --git a/homeassistant/components/sensor/translations/nl.json b/homeassistant/components/sensor/translations/nl.json index 69e18061858c47468eaeb0ef0b333df4c8e27afc..aaf71635015d313df7e24fca6229945c1cdf0ce5 100644 --- a/homeassistant/components/sensor/translations/nl.json +++ b/homeassistant/components/sensor/translations/nl.json @@ -6,11 +6,13 @@ "is_carbon_dioxide": "Huidig niveau {entity_name} kooldioxideconcentratie", "is_carbon_monoxide": "Huidig niveau {entity_name} koolmonoxideconcentratie", "is_current": "Huidige {entity_name} stroom", + "is_distance": "Huidig afstand van {entity_name}", "is_energy": "Huidige {entity_name} energie", "is_frequency": "Huidige {entity_name} frequentie", "is_gas": "Huidig {entity_name} gas", "is_humidity": "Huidige {entity_name} vochtigheidsgraad", "is_illuminance": "Huidige {entity_name} verlichtingssterkte", + "is_moisture": "Huidige vochtigheid van {entity_name}", "is_nitrogen_dioxide": "Huidige {entity_name} stikstofdioxideconcentratie", "is_nitrogen_monoxide": "Huidige {entity_name} stikstofmonoxideconcentratie", "is_nitrous_oxide": "Huidige {entity_name} distikstofmonoxideconcentratie", @@ -23,11 +25,14 @@ "is_pressure": "Huidige {entity_name} druk", "is_reactive_power": "Huidig {entity_name} blindvermogen", "is_signal_strength": "Huidige {entity_name} signaalsterkte", + "is_speed": "Huidige snelheid van {entity_name}", "is_sulphur_dioxide": "Huidige {entity_name} zwaveldioxideconcentratie", "is_temperature": "Huidige {entity_name} temperatuur", "is_value": "Huidige {entity_name} waarde", "is_volatile_organic_compounds": "Huidig {entity_name} vluchtige-organische-stoffenconcentratieniveau", - "is_voltage": "Huidige {entity_name} spanning" + "is_voltage": "Huidige {entity_name} spanning", + "is_volume": "Huidig volume van {entity_name}", + "is_weight": "Huidig gewicht van {entity_name}" }, "trigger_type": { "apparent_power": "{entity_name} schijnbare vermogensveranderingen", @@ -35,11 +40,13 @@ "carbon_dioxide": "{entity_name} kooldioxideconcentratie gewijzigd", "carbon_monoxide": "{entity_name} koolmonoxideconcentratie gewijzigd", "current": "{entity_name} huidige wijzigingen", + "distance": "Afstand van {entity_name} veranderd", "energy": "{entity_name} energieveranderingen", "frequency": "{entity_name} frequentie verandert", "gas": "{entity_name} gas verandert", "humidity": "{entity_name} vochtigheidsgraad gewijzigd", "illuminance": "{entity_name} verlichtingssterkte gewijzigd", + "moisture": "Vochtigheid van {entity_name} veranderd", "nitrogen_dioxide": "{entity_name} stikstofdioxideconcentratieverandering", "nitrogen_monoxide": "{entity_name} stikstofmonoxideconcentratieverandering", "nitrous_oxide": "{entity_name} distikstofmonoxideconcentratieverandering", @@ -52,11 +59,14 @@ "pressure": "{entity_name} druk gewijzigd", "reactive_power": "{entity_name} blindvermogen veranderingen", "signal_strength": "{entity_name} signaalsterkte gewijzigd", + "speed": "Snelheid van {entity_name} veranderd", "sulphur_dioxide": "{entity_name} zwaveldioxideconcentratieveranderingen", "temperature": "{entity_name} temperatuur gewijzigd", "value": "{entity_name} waarde gewijzigd", "volatile_organic_compounds": "{entity_name} vluchtige-organische-stoffenconcentratieveranderingen", - "voltage": "{entity_name} voltage verandert" + "voltage": "{entity_name} voltage verandert", + "volume": "Volume van {entity_name} veranderd", + "weight": "Gewicht van {entity_name} veranderd" } }, "state": { diff --git a/homeassistant/components/sensor/translations/no.json b/homeassistant/components/sensor/translations/no.json index e5b9c70846faa4e8ebcf386a81531ab83ad5e087..53ef0c009420967ef41558643e987e538d4642c8 100644 --- a/homeassistant/components/sensor/translations/no.json +++ b/homeassistant/components/sensor/translations/no.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "Gjeldende {entity_name} karbondioksidkonsentrasjonsniv\u00e5", "is_carbon_monoxide": "Gjeldende {entity_name} karbonmonoksid konsentrasjonsniv\u00e5", "is_current": "Gjeldende {entity_name} str\u00f8m", + "is_distance": "Gjeldende avstand til {entity_name}", "is_energy": "Gjeldende {entity_name} effekt", "is_frequency": "Gjeldende {entity_name} -frekvens", "is_gas": "Gjeldende {entity_name} gass", @@ -24,11 +25,15 @@ "is_pressure": "Gjeldende {entity_name} trykk", "is_reactive_power": "N\u00e5v\u00e6rende reaktiv effekt for {entity_name}", "is_signal_strength": "Gjeldende {entity_name} signalstyrke", + "is_speed": "Gjeldende hastighet {entity_name}", "is_sulphur_dioxide": "Gjeldende konsentrasjonsniv\u00e5 for svoveldioksid for {entity_name}", "is_temperature": "Gjeldende {entity_name} temperatur", "is_value": "Gjeldende {entity_name} verdi", "is_volatile_organic_compounds": "Gjeldende {entity_name} flyktige organiske forbindelser", - "is_voltage": "Gjeldende {entity_name} spenning" + "is_voltage": "Gjeldende {entity_name} spenning", + "is_volume": "Gjeldende {entity_name} -volum", + "is_water": "N\u00e5v\u00e6rende {entity_name} vann", + "is_weight": "N\u00e5v\u00e6rende vekt p\u00e5 {entity_name}" }, "trigger_type": { "apparent_power": "{entity_name} tilsynelatende kraftendringer", @@ -36,6 +41,7 @@ "carbon_dioxide": "{entity_name} endringer i konsentrasjonen av karbondioksid", "carbon_monoxide": "{entity_name} endringer i konsentrasjonen av karbonmonoksid", "current": "{entity_name} gjeldende endringer", + "distance": "{entity_name} avstandsendringer", "energy": "{entity_name} effektendringer", "frequency": "{entity_name} frekvensendringer", "gas": "{entity_name} gass endres", @@ -54,11 +60,15 @@ "pressure": "{entity_name} trykk endringer", "reactive_power": "{entity_name} endringer i reaktiv effekt", "signal_strength": "{entity_name} signalstyrkeendringer", + "speed": "{entity_name} hastighetsendringer", "sulphur_dioxide": "{entity_name} svoveldioksidkonsentrasjon endres", "temperature": "{entity_name} temperaturendringer", "value": "{entity_name} verdi endringer", "volatile_organic_compounds": "{entity_name} konsentrasjon av flyktige organiske forbindelser", - "voltage": "{entity_name} spenningsendringer" + "voltage": "{entity_name} spenningsendringer", + "volume": "{entity_name} volumendringer", + "water": "{entity_name} vannforandringer", + "weight": "Vektendringer {entity_name}" } }, "state": { diff --git a/homeassistant/components/sensor/translations/pl.json b/homeassistant/components/sensor/translations/pl.json index 360d7a2509bbdc22cfdeb130bc23c8c2489eb68f..2accf6eaf52c94fe50982ea7039d9da8bf191403 100644 --- a/homeassistant/components/sensor/translations/pl.json +++ b/homeassistant/components/sensor/translations/pl.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "obecny poziom st\u0119\u017cenia dwutlenku w\u0119gla w {entity_name}", "is_carbon_monoxide": "obecny poziom st\u0119\u017cenia tlenku w\u0119gla w {entity_name}", "is_current": "obecne nat\u0119\u017cenie pr\u0105du {entity_name}", + "is_distance": "obecna odleg\u0142o\u015b\u0107 {entity_name}", "is_energy": "obecna energia {entity_name}", "is_frequency": "obecna cz\u0119stotliwo\u015b\u0107 {entity_name}", "is_gas": "obecny poziom gazu {entity_name}", @@ -24,11 +25,15 @@ "is_pressure": "obecne ci\u015bnienie {entity_name}", "is_reactive_power": "aktualna moc bierna {entity_name}", "is_signal_strength": "obecna si\u0142a sygna\u0142u {entity_name}", + "is_speed": "obecna pr\u0119dko\u015b\u0107 {entity_name}", "is_sulphur_dioxide": "obecny poziom st\u0119\u017cenia dwutlenku siarki {entity_name}", "is_temperature": "obecna temperatura {entity_name}", "is_value": "obecna warto\u015b\u0107 {entity_name}", "is_volatile_organic_compounds": "obecny poziom st\u0119\u017cenia lotnych zwi\u0105zk\u00f3w organicznych {entity_name}", - "is_voltage": "obecne napi\u0119cie {entity_name}" + "is_voltage": "obecne napi\u0119cie {entity_name}", + "is_volume": "obecna obj\u0119to\u015b\u0107 {entity_name}", + "is_water": "obecny poziom wody {entity_name}", + "is_weight": "obecna waga {entity_name}" }, "trigger_type": { "apparent_power": "zmieni si\u0119 moc pozorna {entity_name}", @@ -36,6 +41,7 @@ "carbon_dioxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia dwutlenku w\u0119gla", "carbon_monoxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia tlenku w\u0119gla", "current": "zmieni si\u0119 nat\u0119\u017cenie pr\u0105du w {entity_name}", + "distance": "zmieni si\u0119 odleg\u0142o\u015b\u0107 {entity_name}", "energy": "zmieni si\u0119 energia {entity_name}", "frequency": "zmieni si\u0119 cz\u0119stotliwo\u015b\u0107 w {entity_name}", "gas": "{entity_name} wykryje zmian\u0119 poziomu gazu", @@ -54,11 +60,15 @@ "pressure": "zmieni si\u0119 ci\u015bnienie {entity_name}", "reactive_power": "zmieni si\u0119 moc bierna {entity_name}", "signal_strength": "zmieni si\u0119 si\u0142a sygna\u0142u {entity_name}", + "speed": "zmieni si\u0119 pr\u0119dko\u015b\u0107 {entity_name}", "sulphur_dioxide": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia dwutlenku siarki", "temperature": "zmieni si\u0119 temperatura {entity_name}", "value": "zmieni si\u0119 warto\u015b\u0107 {entity_name}", "volatile_organic_compounds": "{entity_name} wykryje zmian\u0119 st\u0119\u017cenia lotnych zwi\u0105zk\u00f3w organicznych", - "voltage": "zmieni si\u0119 napi\u0119cie w {entity_name}" + "voltage": "zmieni si\u0119 napi\u0119cie w {entity_name}", + "volume": "zmieni si\u0119 obj\u0119to\u015b\u0107 {entity_name}", + "water": "zmieni si\u0119 poziom wody {entity_name}", + "weight": "zmieni si\u0119 waga {entity_name}" } }, "state": { diff --git a/homeassistant/components/sensor/translations/pt-BR.json b/homeassistant/components/sensor/translations/pt-BR.json index 436a43056f196659ad0f1afecbd122a1627817e0..6284a2dd17091aea2add951162d2b188bb803277 100644 --- a/homeassistant/components/sensor/translations/pt-BR.json +++ b/homeassistant/components/sensor/translations/pt-BR.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "N\u00edvel atual de concentra\u00e7\u00e3o de di\u00f3xido de carbono de {entity_name}", "is_carbon_monoxide": "N\u00edvel de concentra\u00e7\u00e3o de mon\u00f3xido de carbono atual de {entity_name}", "is_current": "Corrente atual de {entity_name}", + "is_distance": "Dist\u00e2ncia atual de {entity_name}", "is_energy": "Energia atual de {entity_name}", "is_frequency": "Frequ\u00eancia atual de {entity_name}", "is_gas": "G\u00e1s atual de {entity_name}", @@ -24,11 +25,15 @@ "is_pressure": "Press\u00e3o atual do(a) {entity_name}", "is_reactive_power": "Pot\u00eancia reativa atual de {entity_name}", "is_signal_strength": "For\u00e7a do sinal atual do(a) {entity_name}", + "is_speed": "Velocidade atual de {entity_name}", "is_sulphur_dioxide": "N\u00edvel atual de concentra\u00e7\u00e3o de di\u00f3xido de enxofre de {entity_name}", "is_temperature": "Temperatura atual do(a) {entity_name}", "is_value": "Valor atual de {entity_name}", "is_volatile_organic_compounds": "N\u00edvel atual de concentra\u00e7\u00e3o de compostos org\u00e2nicos vol\u00e1teis de {entity_name}", - "is_voltage": "Tens\u00e3o atual de {entity_name}" + "is_voltage": "Tens\u00e3o atual de {entity_name}", + "is_volume": "Volume atual de {entity_name}", + "is_water": "\u00c1gua atual {entity_name}", + "is_weight": "Peso atual {entity_name}" }, "trigger_type": { "apparent_power": "Mudan\u00e7as de poder aparentes de {entity_name}", @@ -36,6 +41,7 @@ "carbon_dioxide": "Mudan\u00e7as na concentra\u00e7\u00e3o de di\u00f3xido de carbono de {entity_name}", "carbon_monoxide": "Altera\u00e7\u00f5es na concentra\u00e7\u00e3o de mon\u00f3xido de carbono de {entity_name}", "current": "Mudan\u00e7a na corrente de {entity_name}", + "distance": "Mudan\u00e7as da dist\u00e2ncia de {entity_name}", "energy": "Mudan\u00e7as na energia de {entity_name}", "frequency": "Altera\u00e7\u00f5es de frequ\u00eancia de {entity_name}", "gas": "Mudan\u00e7as de g\u00e1s de {entity_name}", @@ -54,11 +60,15 @@ "pressure": "{entity_name} mudan\u00e7as de press\u00e3o", "reactive_power": "Altera\u00e7\u00f5es de pot\u00eancia reativa de {entity_name}", "signal_strength": "{entity_name} muda a for\u00e7a do sinal", + "speed": "Mudan\u00e7as de velocidade de {entity_name}", "sulphur_dioxide": "Altera\u00e7\u00f5es na concentra\u00e7\u00e3o de di\u00f3xido de enxofre de {entity_name}", "temperature": "{entity_name} mudan\u00e7as de temperatura", "value": "{entity_name} mudan\u00e7as de valor", "volatile_organic_compounds": "Altera\u00e7\u00f5es na concentra\u00e7\u00e3o de compostos org\u00e2nicos vol\u00e1teis de {entity_name}", - "voltage": "Mudan\u00e7as de voltagem de {entity_name}" + "voltage": "Mudan\u00e7as de voltagem de {entity_name}", + "volume": "Altera\u00e7\u00f5es de volume de {entity_name}", + "water": "mudan\u00e7as de \u00e1gua {entity_name}", + "weight": "Altera\u00e7\u00f5es de peso {entity_name}" } }, "state": { diff --git a/homeassistant/components/sensor/translations/ru.json b/homeassistant/components/sensor/translations/ru.json index 1efde71a7c6506f39651a8bba39901f6419de8f2..9b7a1f7dbe88c44e5abb1fdcf78d7e90cba6897c 100644 --- a/homeassistant/components/sensor/translations/ru.json +++ b/homeassistant/components/sensor/translations/ru.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0433\u043b\u0435\u043a\u0438\u0441\u043b\u043e\u0433\u043e \u0433\u0430\u0437\u0430", "is_carbon_monoxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0433\u0430\u0440\u043d\u043e\u0433\u043e \u0433\u0430\u0437\u0430", "is_current": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u0438\u043b\u044b \u0442\u043e\u043a\u0430", + "is_distance": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_energy": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438", "is_frequency": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_gas": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", @@ -24,11 +25,14 @@ "is_pressure": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_reactive_power": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_signal_strength": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "is_speed": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_sulphur_dioxide": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0434\u0438\u043e\u043a\u0441\u0438\u0434\u0430 \u0441\u0435\u0440\u044b", "is_temperature": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_value": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "is_volatile_organic_compounds": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0443\u0440\u043e\u0432\u043d\u044f \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043b\u0435\u0442\u0443\u0447\u0438\u0445 \u043e\u0440\u0433\u0430\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0445 \u0432\u0435\u0449\u0435\u0441\u0442\u0432", - "is_voltage": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" + "is_voltage": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f", + "is_volume": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "is_weight": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435" }, "trigger_type": { "apparent_power": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u043d\u043e\u0439 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438", @@ -36,6 +40,7 @@ "carbon_dioxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "carbon_monoxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "current": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u0438\u043b\u044b \u0442\u043e\u043a\u0430", + "distance": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0440\u0430\u0441\u0441\u0442\u043e\u044f\u043d\u0438\u0435", "energy": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438", "frequency": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "gas": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435 \u0433\u0430\u0437\u0430", @@ -54,11 +59,14 @@ "pressure": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "reactive_power": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0440\u0435\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0439 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438", "signal_strength": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", + "speed": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c", "sulphur_dioxide": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u0434\u0438\u043e\u043a\u0441\u0438\u0434\u0430 \u0441\u0435\u0440\u044b", "temperature": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "value": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435", "volatile_organic_compounds": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\u0438\u0438 \u043b\u0435\u0442\u0443\u0447\u0438\u0445 \u043e\u0440\u0433\u0430\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0445 \u0432\u0435\u0449\u0435\u0441\u0442\u0432", - "voltage": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" + "voltage": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f", + "volume": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u043e\u0431\u044a\u0451\u043c", + "weight": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0432\u0435\u0441" } }, "state": { diff --git a/homeassistant/components/sensor/translations/sv.json b/homeassistant/components/sensor/translations/sv.json index 544bf563bcc676b971deccca4b0599b49fa9170c..3113900537546c45e06793f65f1541c53bc5d2f7 100644 --- a/homeassistant/components/sensor/translations/sv.json +++ b/homeassistant/components/sensor/translations/sv.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "Nuvarande {entity_name} koncentration av koldioxid", "is_carbon_monoxide": "Nuvarande {entity_name} koncentration av kolmonoxid", "is_current": "Nuvarande", + "is_distance": "Aktuellt avst\u00e5nd {entity_name}", "is_energy": "Nuvarande {entity_name} energi", "is_frequency": "Nuvarande frekvens", "is_gas": "Nuvarande {entity_name} gas", @@ -24,11 +25,14 @@ "is_pressure": "Aktuellt {entity_name} tryck", "is_reactive_power": "Nuvarande {entity_name} reaktiv effekt", "is_signal_strength": "Nuvarande {entity_name} signalstyrka", + "is_speed": "Aktuell hastighet {entity_name}", "is_sulphur_dioxide": "Nuvarande koncentration av svaveldioxid i {entity_name}.", "is_temperature": "Aktuell {entity_name} temperatur", "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" + "is_voltage": "Nuvarande {entity_name} sp\u00e4nning", + "is_volume": "Aktuell volym {entity_name}", + "is_weight": "Nuvarande vikt {entity_name}" }, "trigger_type": { "apparent_power": "{entity_name} uppenbara effektf\u00f6r\u00e4ndringar", @@ -36,6 +40,7 @@ "carbon_dioxide": "{entity_name} f\u00f6r\u00e4ndringar av koldioxidkoncentrationen", "carbon_monoxide": "{entity_name} f\u00f6r\u00e4ndringar av kolmonoxidkoncentrationen", "current": "{entity_name} aktuella \u00e4ndringar", + "distance": "{entity_name} avst\u00e5ndsf\u00f6r\u00e4ndringar", "energy": "Energif\u00f6r\u00e4ndringar", "frequency": "{entity_name} frekvens\u00e4ndringar", "gas": "{entity_name} gasf\u00f6r\u00e4ndringar", @@ -54,11 +59,14 @@ "pressure": "{entity_name} tryckf\u00f6r\u00e4ndringar", "reactive_power": "{entity_name} reaktiv effekt\u00e4ndring", "signal_strength": "{entity_name} signalstyrka \u00e4ndras", + "speed": "{entity_name} hastighets\u00e4ndringar", "sulphur_dioxide": "{entity_name} f\u00f6r\u00e4ndringar av koncentrationen av svaveldioxid", "temperature": "{entity_name} temperaturf\u00f6r\u00e4ndringar", "value": "{entity_name} v\u00e4rde \u00e4ndras", "volatile_organic_compounds": "{entity_name} koncentrations\u00e4ndringar av flyktiga organiska \u00e4mnen", - "voltage": "{entity_name} sp\u00e4nningsf\u00f6r\u00e4ndringar" + "voltage": "{entity_name} sp\u00e4nningsf\u00f6r\u00e4ndringar", + "volume": "{entity_name} volymf\u00f6r\u00e4ndringar", + "weight": "{entity_name} viktf\u00f6r\u00e4ndringar" } }, "state": { diff --git a/homeassistant/components/sensor/translations/tr.json b/homeassistant/components/sensor/translations/tr.json index cc7cf3f39fab8274aa5ea0c0315647cf2411eb5e..33b43342c81f8d86567939d041af77b1e4f8eab7 100644 --- a/homeassistant/components/sensor/translations/tr.json +++ b/homeassistant/components/sensor/translations/tr.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "Mevcut {entity_name} karbondioksit konsantrasyon seviyesi", "is_carbon_monoxide": "Mevcut {entity_name} karbon monoksit konsantrasyon seviyesi", "is_current": "Mevcut {entity_name} ak\u0131m\u0131", + "is_distance": "Mevcut {entity_name} mesafesi", "is_energy": "Mevcut {entity_name} enerjisi", "is_frequency": "Ge\u00e7erli {entity_name} frekans\u0131", "is_gas": "Mevcut {entity_name} gaz\u0131", @@ -24,11 +25,15 @@ "is_pressure": "Ge\u00e7erli {entity_name} bas\u0131nc\u0131", "is_reactive_power": "Mevcut {entity_name} reaktif g\u00fc\u00e7", "is_signal_strength": "Mevcut {entity_name} sinyal g\u00fcc\u00fc", + "is_speed": "Mevcut {entity_name} h\u0131z\u0131", "is_sulphur_dioxide": "Mevcut {entity_name} k\u00fck\u00fcrt dioksit konsantrasyon seviyesi", "is_temperature": "Mevcut {entity_name} s\u0131cakl\u0131\u011f\u0131", "is_value": "Mevcut {entity_name} de\u011feri", "is_volatile_organic_compounds": "Mevcut {entity_name} u\u00e7ucu organik bile\u015fik konsantrasyon seviyesi", - "is_voltage": "Mevcut {entity_name} voltaj\u0131" + "is_voltage": "Mevcut {entity_name} voltaj\u0131", + "is_volume": "Mevcut {entity_name} birimi", + "is_water": "Mevcut {entity_name} su", + "is_weight": "Mevcut {entity_name} a\u011f\u0131rl\u0131\u011f\u0131" }, "trigger_type": { "apparent_power": "{entity_name} g\u00f6r\u00fcn\u00fcr g\u00fc\u00e7 de\u011fi\u015fiklikleri", @@ -36,6 +41,7 @@ "carbon_dioxide": "{entity_name} karbondioksit konsantrasyonu de\u011fi\u015fiklikleri", "carbon_monoxide": "{entity_name} karbon monoksit konsantrasyonu de\u011fi\u015fiklikleri", "current": "{entity_name} ak\u0131m de\u011fi\u015fiklikleri", + "distance": "{entity_name} mesafe de\u011fi\u015fiklikleri", "energy": "{entity_name} enerji de\u011fi\u015fiklikleri", "frequency": "{entity_name} frekans de\u011fi\u015fiklikleri", "gas": "{entity_name} gaz de\u011fi\u015fiklikleri", @@ -54,11 +60,15 @@ "pressure": "{entity_name} bas\u0131n\u00e7 de\u011fi\u015fiklikleri", "reactive_power": "{entity_name} reaktif g\u00fc\u00e7 de\u011fi\u015fiklikleri", "signal_strength": "{entity_name} sinyal g\u00fcc\u00fc de\u011fi\u015fiklikleri", + "speed": "{entity_name} h\u0131z de\u011fi\u015fiklikleri", "sulphur_dioxide": "{entity_name} k\u00fck\u00fcrt dioksit konsantrasyonu de\u011fi\u015fiklikleri", "temperature": "{entity_name} s\u0131cakl\u0131k de\u011fi\u015fiklikleri", "value": "{entity_name} de\u011fer de\u011fi\u015fiklikleri", "volatile_organic_compounds": "{entity_name} u\u00e7ucu organik bile\u015fik konsantrasyonu de\u011fi\u015fiklikleri", - "voltage": "{entity_name} voltaj de\u011fi\u015fiklikleri" + "voltage": "{entity_name} voltaj de\u011fi\u015fiklikleri", + "volume": "{entity_name} birim de\u011fi\u015fiklikleri", + "water": "{entity_name} su de\u011fi\u015fimi", + "weight": "{entity_name} a\u011f\u0131rl\u0131k de\u011fi\u015fiklikleri" } }, "state": { diff --git a/homeassistant/components/sensor/translations/zh-Hant.json b/homeassistant/components/sensor/translations/zh-Hant.json index eb0bbfc50f93e91d2b3683e49dc3492eef87cba4..1adb71c652bed8f66489e83be692f6fae51d2bc4 100644 --- a/homeassistant/components/sensor/translations/zh-Hant.json +++ b/homeassistant/components/sensor/translations/zh-Hant.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "\u76ee\u524d {entity_name} \u4e8c\u6c27\u5316\u78b3\u6fc3\u5ea6\u72c0\u614b", "is_carbon_monoxide": "\u76ee\u524d {entity_name} \u4e00\u6c27\u5316\u78b3\u6fc3\u5ea6\u72c0\u614b", "is_current": "\u76ee\u524d{entity_name}\u96fb\u6d41", + "is_distance": "\u76ee\u524d{entity_name}\u8ddd\u96e2", "is_energy": "\u76ee\u524d{entity_name}\u96fb\u529b", "is_frequency": "\u76ee\u524d{entity_name}\u983b\u7387", "is_gas": "\u76ee\u524d{entity_name}\u6c23\u9ad4", @@ -24,11 +25,15 @@ "is_pressure": "\u76ee\u524d{entity_name}\u58d3\u529b", "is_reactive_power": "\u76ee\u524d{entity_name}\u7121\u6548\u529f\u7387", "is_signal_strength": "\u76ee\u524d{entity_name}\u8a0a\u865f\u5f37\u5ea6", + "is_speed": "\u76ee\u524d{entity_name}\u901f\u5ea6", "is_sulphur_dioxide": "\u76ee\u524d {entity_name} \u4e8c\u6c27\u5316\u786b\u6fc3\u5ea6\u72c0\u614b", "is_temperature": "\u76ee\u524d{entity_name}\u6eab\u5ea6", "is_value": "\u76ee\u524d{entity_name}\u503c", "is_volatile_organic_compounds": "\u76ee\u524d {entity_name} \u63ee\u767c\u6027\u6709\u6a5f\u7269\u6fc3\u5ea6\u72c0\u614b", - "is_voltage": "\u76ee\u524d{entity_name}\u96fb\u58d3" + "is_voltage": "\u76ee\u524d{entity_name}\u96fb\u58d3", + "is_volume": "\u76ee\u524d{entity_name}\u9ad4\u7a4d", + "is_water": "\u76ee\u524d{entity_name}\u6c34\u4f4d", + "is_weight": "\u76ee\u524d{entity_name}\u91cd\u91cf" }, "trigger_type": { "apparent_power": "{entity_name}\u8996\u5728\u529f\u7387\u8b8a\u66f4", @@ -36,6 +41,7 @@ "carbon_dioxide": "{entity_name} \u4e8c\u6c27\u5316\u78b3\u6fc3\u5ea6\u8b8a\u5316", "carbon_monoxide": "{entity_name} \u4e00\u6c27\u5316\u78b3\u6fc3\u5ea6\u8b8a\u5316", "current": "\u76ee\u524d{entity_name}\u96fb\u6d41\u8b8a\u66f4", + "distance": "{entity_name}\u8ddd\u96e2\u8b8a\u66f4", "energy": "\u76ee\u524d{entity_name}\u96fb\u529b\u8b8a\u66f4", "frequency": "{entity_name}\u983b\u7387\u8b8a\u66f4", "gas": "{entity_name}\u6c23\u9ad4\u8b8a\u66f4", @@ -54,11 +60,15 @@ "pressure": "{entity_name}\u58d3\u529b\u8b8a\u66f4", "reactive_power": "{entity_name}\u7121\u6548\u529f\u7387\u8b8a\u66f4", "signal_strength": "{entity_name}\u8a0a\u865f\u5f37\u5ea6\u8b8a\u66f4", + "speed": "{entity_name}\u901f\u5ea6\u8b8a\u66f4", "sulphur_dioxide": "{entity_name} \u4e8c\u6c27\u5316\u786b\u6fc3\u5ea6\u8b8a\u5316", "temperature": "{entity_name}\u6eab\u5ea6\u8b8a\u66f4", "value": "{entity_name}\u503c\u8b8a\u66f4", "volatile_organic_compounds": "{entity_name} \u63ee\u767c\u6027\u6709\u6a5f\u7269\u6fc3\u5ea6\u8b8a\u5316", - "voltage": "\u76ee\u524d{entity_name}\u96fb\u58d3\u8b8a\u66f4" + "voltage": "\u76ee\u524d{entity_name}\u96fb\u58d3\u8b8a\u66f4", + "volume": "{entity_name}\u9ad4\u7a4d\u8b8a\u66f4", + "water": "{entity_name}\u6c34\u4f4d\u8b8a\u66f4", + "weight": "{entity_name}\u91cd\u91cf\u8b8a\u66f4" } }, "state": { diff --git a/homeassistant/components/sensorblue/manifest.json b/homeassistant/components/sensorblue/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..d74abde6fdb7f1fd7a2cab2ec9f349cf623f2f3b --- /dev/null +++ b/homeassistant/components/sensorblue/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "sensorblue", + "name": "SensorBlue", + "integration_type": "virtual", + "supported_by": "thermobeacon" +} diff --git a/homeassistant/components/sensorpro/translations/he.json b/homeassistant/components/sensorpro/translations/he.json index 47308062d0d426cb13dd9e46494762b2e48d2482..b182a698234a65d0e9dfc6fead874529e3d529b7 100644 --- a/homeassistant/components/sensorpro/translations/he.json +++ b/homeassistant/components/sensorpro/translations/he.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "not_supported": "\u05d4\u05ea\u05e7\u05df \u05d0\u05d9\u05e0\u05d5 \u05e0\u05ea\u05de\u05da" + }, "flow_title": "{name}", "step": { "bluetooth_confirm": { diff --git a/homeassistant/components/sensorpro/translations/hu.json b/homeassistant/components/sensorpro/translations/hu.json index 97fbb5b940835353812ebfe6bb39907626ddb6e9..4668ffea41696296cf59192c6561163e058b2a49 100644 --- a/homeassistant/components/sensorpro/translations/hu.json +++ b/homeassistant/components/sensorpro/translations/hu.json @@ -15,7 +15,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/sensorpush/translations/hu.json b/homeassistant/components/sensorpush/translations/hu.json index 7ef0d3a63013dc9a7c1814fe3c80d99ab7dede60..e1673194c6d885ee4f8bae1faa811bd0f14444f2 100644 --- a/homeassistant/components/sensorpush/translations/hu.json +++ b/homeassistant/components/sensorpush/translations/hu.json @@ -14,7 +14,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 9855c281ac4bc998bfc2fee948e42345386b15e0..1c4b00e25cccd64e036205ee13cb21e642b4b1b0 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -3,7 +3,8 @@ "name": "Sentry", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sentry", - "requirements": ["sentry-sdk==1.9.8"], + "requirements": ["sentry-sdk==1.10.0"], "codeowners": ["@dcramer", "@frenck"], + "integration_type": "service", "iot_class": "cloud_polling" } diff --git a/homeassistant/components/sentry/translations/nb.json b/homeassistant/components/sentry/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/sentry/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/senz/translations/id.json b/homeassistant/components/senz/translations/id.json index 6f4eec8f3b900f932fbad9ad61448b2e2dde63a3..80e198427fe60ff5d6041f6da3d18360a5a653f8 100644 --- a/homeassistant/components/senz/translations/id.json +++ b/homeassistant/components/senz/translations/id.json @@ -19,8 +19,8 @@ }, "issues": { "removed_yaml": { - "description": "Proses konfigurasi nVent RAYCHEM SENZ lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML nVent RAYCHEM SENZ telah dihapus" + "description": "Proses konfigurasi Integrasi nVent RAYCHEM SENZ lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi nVent RAYCHEM SENZ telah dihapus" } } } \ No newline at end of file diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index 363eb5077108a4f9a093c6782d92bc73daf6bf87..9cc3c8ffd5770733fe448df93d9847c70826df6f 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -11,7 +11,6 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, ATTR_LOCATION, CONF_PASSWORD, @@ -112,12 +111,13 @@ async def async_setup_platform( class SeventeenTrackSummarySensor(SensorEntity): """Define a summary sensor.""" + _attr_attribution = ATTRIBUTION _attr_icon = "mdi:package" _attr_native_unit_of_measurement = "packages" def __init__(self, data, status, initial_state): """Initialize.""" - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._attr_extra_state_attributes = {} self._data = data self._state = initial_state self._status = status @@ -164,12 +164,12 @@ class SeventeenTrackSummarySensor(SensorEntity): class SeventeenTrackPackageSensor(SensorEntity): """Define an individual package sensor.""" + _attr_attribution = ATTRIBUTION _attr_icon = "mdi:package" def __init__(self, data, package): """Initialize.""" self._attr_extra_state_attributes = { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_DESTINATION_COUNTRY: package.destination_country, ATTR_INFO_TEXT: package.info_text, ATTR_TIMESTAMP: package.timestamp, diff --git a/homeassistant/components/sharkiq/translations/nb.json b/homeassistant/components/sharkiq/translations/nb.json index c7b6400d4769da1b0e0d3de7033af42a3382f427..02b7be76c7ec7d28c0d468e1cc29a8733f21767b 100644 --- a/homeassistant/components/sharkiq/translations/nb.json +++ b/homeassistant/components/sharkiq/translations/nb.json @@ -2,7 +2,10 @@ "config": { "abort": { "cannot_connect": "Tilkobling mislyktes", - "unknown": "Uforventet feil" + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" }, "step": { "reauth": { diff --git a/homeassistant/components/sharkiq/translations/no.json b/homeassistant/components/sharkiq/translations/no.json index 4454bd940d444824d1772199a844dfcff338f00a..f9fe98cf3a4c9f32be6b133db8278cae23c232cd 100644 --- a/homeassistant/components/sharkiq/translations/no.json +++ b/homeassistant/components/sharkiq/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Kontoen er allerede konfigurert", "cannot_connect": "Tilkobling mislyktes", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "unknown": "Uventet feil" }, "error": { diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index ba03cf40f4fde20093aab4ecb8d1a2dd18f9ba04..b65c314789a30cdb6e2f4f0de8c4ec555465359d 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -1,76 +1,44 @@ """The Shelly integration.""" from __future__ import annotations -import asyncio -from collections.abc import Coroutine -from datetime import timedelta -from http import HTTPStatus -from typing import Any, Final, cast +from typing import Any, Final -from aiohttp import ClientResponseError import aioshelly from aioshelly.block_device import BlockDevice -from aioshelly.exceptions import AuthRequired, InvalidAuthError +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from aioshelly.rpc_device import RpcDevice -import async_timeout import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_HOST, - CONF_PASSWORD, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, - Platform, -) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, device_registry, update_coordinator +from homeassistant.helpers import aiohttp_client, device_registry import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.typing import ConfigType from .const import ( - AIOSHELLY_DEVICE_TIMEOUT_SEC, - ATTR_BETA, - ATTR_CHANNEL, - ATTR_CLICK_TYPE, - ATTR_DEVICE, - ATTR_GENERATION, - BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, - BLOCK, CONF_COAP_PORT, CONF_SLEEP_PERIOD, DATA_CONFIG_ENTRY, DEFAULT_COAP_PORT, - DEVICE, DOMAIN, - DUAL_MODE_LIGHT_MODELS, - ENTRY_RELOAD_COOLDOWN, - EVENT_SHELLY_CLICK, - INPUTS_EVENTS_DICT, LOGGER, - MODELS_SUPPORTING_LIGHT_EFFECTS, - POLLING_TIMEOUT_SEC, - REST, - REST_SENSORS_UPDATE_INTERVAL, - RPC, - RPC_INPUTS_EVENTS_TYPES, - RPC_POLL, - RPC_RECONNECT_INTERVAL, - RPC_SENSORS_POLLING_INTERVAL, - SHBTN_MODELS, - SLEEP_PERIOD_MULTIPLIER, - UPDATE_PERIOD_MULTIPLIER, +) +from .coordinator import ( + ShellyBlockCoordinator, + ShellyEntryData, + ShellyRestCoordinator, + ShellyRpcCoordinator, + ShellyRpcPollingCoordinator, + get_entry_data, ) from .utils import ( - device_update_info, - get_block_device_name, get_block_device_sleep_period, get_coap_context, get_device_entry_gen, - get_rpc_device_name, + get_rpc_device_sleep_period, + get_ws_context, ) BLOCK_PLATFORMS: Final = [ @@ -97,7 +65,10 @@ RPC_PLATFORMS: Final = [ Platform.SWITCH, Platform.UPDATE, ] - +RPC_SLEEPING_PLATFORMS: Final = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] COAP_SCHEMA: Final = vol.Schema( { @@ -131,24 +102,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None + get_entry_data(hass)[entry.entry_id] = ShellyEntryData() if get_device_entry_gen(entry) == 2: - return await async_setup_rpc_entry(hass, entry) + return await _async_setup_rpc_entry(hass, entry) - return await async_setup_block_entry(hass, entry) + return await _async_setup_block_entry(hass, entry) -async def async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Shelly block based device from a config entry.""" - temperature_unit = "C" if hass.config.units.is_metric else "F" - options = aioshelly.common.ConnectionOptions( entry.data[CONF_HOST], entry.data.get(CONF_USERNAME), entry.data.get(CONF_PASSWORD), - temperature_unit, ) coap_context = await get_coap_context(hass) @@ -172,15 +139,31 @@ async def async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> bo ) }, ) + # https://github.com/home-assistant/core/pull/48076 if device_entry and entry.entry_id not in device_entry.config_entries: device_entry = None sleep_period = entry.data.get(CONF_SLEEP_PERIOD) + shelly_entry_data = get_entry_data(hass)[entry.entry_id] + + @callback + def _async_block_device_setup() -> None: + """Set up a block based device that is online.""" + shelly_entry_data.block = ShellyBlockCoordinator(hass, entry, device) + shelly_entry_data.block.async_setup() + + platforms = BLOCK_SLEEPING_PLATFORMS + + if not entry.data.get(CONF_SLEEP_PERIOD): + shelly_entry_data.rest = ShellyRestCoordinator(hass, device, entry) + platforms = BLOCK_PLATFORMS + + hass.config_entries.async_setup_platforms(entry, platforms) @callback def _async_device_online(_: Any) -> None: LOGGER.debug("Device %s is online, resuming setup", entry.title) - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None + shelly_entry_data.device = None if sleep_period is None: data = {**entry.data} @@ -188,31 +171,22 @@ async def async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> bo data["model"] = device.settings["device"]["type"] hass.config_entries.async_update_entry(entry, data=data) - async_block_device_setup(hass, entry, device) + _async_block_device_setup() if sleep_period == 0: # Not a sleeping device, finish setup LOGGER.debug("Setting up online block device %s", entry.title) try: - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - await device.initialize() - await device.update_status() - except asyncio.TimeoutError as err: - raise ConfigEntryNotReady( - str(err) or "Timeout during device setup" - ) from err - except OSError as err: - raise ConfigEntryNotReady(str(err) or "Error during device setup") from err - except AuthRequired as err: - raise ConfigEntryAuthFailed from err - except ClientResponseError as err: - if err.status == HTTPStatus.UNAUTHORIZED: - raise ConfigEntryAuthFailed from err - - async_block_device_setup(hass, entry, device) + await device.initialize() + except DeviceConnectionError as err: + raise ConfigEntryNotReady(repr(err)) from err + except InvalidAuthError as err: + raise ConfigEntryAuthFailed(repr(err)) from err + + _async_block_device_setup() elif sleep_period is None or device_entry is None: # Need to get sleep info or first time sleeping device setup, wait for device - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = device + shelly_entry_data.device = device LOGGER.debug( "Setup for device %s will resume when device is online", entry.title ) @@ -220,33 +194,12 @@ async def async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> bo else: # Restore sensors for sleeping device LOGGER.debug("Setting up offline block device %s", entry.title) - async_block_device_setup(hass, entry, device) + _async_block_device_setup() return True -@callback -def async_block_device_setup( - hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice -) -> None: - """Set up a block based device that is online.""" - device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ - BLOCK - ] = BlockDeviceWrapper(hass, entry, device) - device_wrapper.async_setup() - - platforms = BLOCK_SLEEPING_PLATFORMS - - if not entry.data.get(CONF_SLEEP_PERIOD): - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ - REST - ] = ShellyDeviceRestWrapper(hass, device, entry) - platforms = BLOCK_PLATFORMS - - hass.config_entries.async_setup_platforms(entry, platforms) - - -async def async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Shelly RPC based device from a config entry.""" options = aioshelly.common.ConnectionOptions( entry.data[CONF_HOST], @@ -254,587 +207,124 @@ async def async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool entry.data.get(CONF_PASSWORD), ) - LOGGER.debug("Setting up online RPC device %s", entry.title) - try: - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - device = await RpcDevice.create( - aiohttp_client.async_get_clientsession(hass), options - ) - except asyncio.TimeoutError as err: - raise ConfigEntryNotReady(str(err) or "Timeout during device setup") from err - except OSError as err: - raise ConfigEntryNotReady(str(err) or "Error during device setup") from err - except (AuthRequired, InvalidAuthError) as err: - raise ConfigEntryAuthFailed from err - - device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ - RPC - ] = RpcDeviceWrapper(hass, entry, device) - device_wrapper.async_setup() - - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][RPC_POLL] = RpcPollingWrapper( - hass, entry, device - ) + ws_context = await get_ws_context(hass) - hass.config_entries.async_setup_platforms(entry, RPC_PLATFORMS) + device = await RpcDevice.create( + aiohttp_client.async_get_clientsession(hass), + ws_context, + options, + False, + ) - return True + dev_reg = device_registry.async_get(hass) + device_entry = None + if entry.unique_id is not None: + device_entry = dev_reg.async_get_device( + identifiers=set(), + connections={ + ( + device_registry.CONNECTION_NETWORK_MAC, + device_registry.format_mac(entry.unique_id), + ) + }, + ) + # https://github.com/home-assistant/core/pull/48076 + if device_entry and entry.entry_id not in device_entry.config_entries: + device_entry = None + sleep_period = entry.data.get(CONF_SLEEP_PERIOD) + shelly_entry_data = get_entry_data(hass)[entry.entry_id] -class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator): - """Wrapper for a Shelly block based device with Home Assistant specific functions.""" + @callback + def _async_rpc_device_setup() -> None: + """Set up a RPC based device that is online.""" + shelly_entry_data.rpc = ShellyRpcCoordinator(hass, entry, device) + shelly_entry_data.rpc.async_setup() - def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice - ) -> None: - """Initialize the Shelly device wrapper.""" - self.device_id: str | None = None + platforms = RPC_SLEEPING_PLATFORMS - if sleep_period := entry.data[CONF_SLEEP_PERIOD]: - update_interval = SLEEP_PERIOD_MULTIPLIER * sleep_period - else: - update_interval = ( - UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] + if not entry.data.get(CONF_SLEEP_PERIOD): + shelly_entry_data.rpc_poll = ShellyRpcPollingCoordinator( + hass, entry, device ) + platforms = RPC_PLATFORMS - device_name = ( - get_block_device_name(device) if device.initialized else entry.title - ) - super().__init__( - hass, - LOGGER, - name=device_name, - update_interval=timedelta(seconds=update_interval), - ) - self.hass = hass - self.entry = entry - self.device = device - - self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer( - hass, - LOGGER, - cooldown=ENTRY_RELOAD_COOLDOWN, - immediate=False, - function=self._async_reload_entry, - ) - entry.async_on_unload(self._debounced_reload.async_cancel) - self._last_cfg_changed: int | None = None - self._last_mode: str | None = None - self._last_effect: int | None = None - - entry.async_on_unload( - self.async_add_listener(self._async_device_updates_handler) - ) - self._last_input_events_count: dict = {} - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) - ) - - async def _async_reload_entry(self) -> None: - """Reload entry.""" - LOGGER.debug("Reloading entry %s", self.name) - await self.hass.config_entries.async_reload(self.entry.entry_id) + hass.config_entries.async_setup_platforms(entry, platforms) @callback - def _async_device_updates_handler(self) -> None: - """Handle device updates.""" - if not self.device.initialized: - return - - assert self.device.blocks - - # For buttons which are battery powered - set initial value for last_event_count - if self.model in SHBTN_MODELS and self._last_input_events_count.get(1) is None: - for block in self.device.blocks: - if block.type != "device": - continue - - if len(block.wakeupEvent) == 1 and block.wakeupEvent[0] == "button": - self._last_input_events_count[1] = -1 - - break - - # Check for input events and config change - cfg_changed = 0 - for block in self.device.blocks: - if block.type == "device": - cfg_changed = block.cfgChanged - - # For dual mode bulbs ignore change if it is due to mode/effect change - if self.model in DUAL_MODE_LIGHT_MODELS: - if "mode" in block.sensor_ids: - if self._last_mode != block.mode: - self._last_cfg_changed = None - self._last_mode = block.mode - - if self.model in MODELS_SUPPORTING_LIGHT_EFFECTS: - if "effect" in block.sensor_ids: - if self._last_effect != block.effect: - self._last_cfg_changed = None - self._last_effect = block.effect - - if ( - "inputEvent" not in block.sensor_ids - or "inputEventCnt" not in block.sensor_ids - ): - continue - - channel = int(block.channel or 0) + 1 - event_type = block.inputEvent - last_event_count = self._last_input_events_count.get(channel) - self._last_input_events_count[channel] = block.inputEventCnt - - if ( - last_event_count is None - or last_event_count == block.inputEventCnt - or event_type == "" - ): - continue - - if event_type in INPUTS_EVENTS_DICT: - self.hass.bus.async_fire( - EVENT_SHELLY_CLICK, - { - ATTR_DEVICE_ID: self.device_id, - ATTR_DEVICE: self.device.settings["device"]["hostname"], - ATTR_CHANNEL: channel, - ATTR_CLICK_TYPE: INPUTS_EVENTS_DICT[event_type], - ATTR_GENERATION: 1, - }, - ) - else: - LOGGER.warning( - "Shelly input event %s for device %s is not supported, please open issue", - event_type, - self.name, - ) - - if self._last_cfg_changed is not None and cfg_changed > self._last_cfg_changed: - LOGGER.info( - "Config for %s changed, reloading entry in %s seconds", - self.name, - ENTRY_RELOAD_COOLDOWN, - ) - self.hass.async_create_task(self._debounced_reload.async_call()) - self._last_cfg_changed = cfg_changed - - async def _async_update_data(self) -> None: - """Fetch data.""" - if sleep_period := self.entry.data.get(CONF_SLEEP_PERIOD): - # Sleeping device, no point polling it, just mark it unavailable - raise update_coordinator.UpdateFailed( - f"Sleeping device did not update within {sleep_period} seconds interval" - ) - - LOGGER.debug("Polling Shelly Block Device - %s", self.name) - try: - async with async_timeout.timeout(POLLING_TIMEOUT_SEC): - await self.device.update() - device_update_info(self.hass, self.device, self.entry) - except OSError as err: - raise update_coordinator.UpdateFailed("Error fetching data") from err - - @property - def model(self) -> str: - """Model of the device.""" - return cast(str, self.entry.data["model"]) - - @property - def mac(self) -> str: - """Mac address of the device.""" - return cast(str, self.entry.unique_id) - - @property - def sw_version(self) -> str: - """Firmware version of the device.""" - return self.device.firmware_version if self.device.initialized else "" - - def async_setup(self) -> None: - """Set up the wrapper.""" - dev_reg = device_registry.async_get(self.hass) - entry = dev_reg.async_get_or_create( - config_entry_id=self.entry.entry_id, - name=self.name, - connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)}, - manufacturer="Shelly", - model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), - sw_version=self.sw_version, - hw_version=f"gen{self.device.gen} ({self.model})", - configuration_url=f"http://{self.entry.data[CONF_HOST]}", - ) - self.device_id = entry.id - self.device.subscribe_updates(self.async_set_updated_data) + def _async_device_online(_: Any) -> None: + LOGGER.debug("Device %s is online, resuming setup", entry.title) + shelly_entry_data.device = None - async def async_trigger_ota_update(self, beta: bool = False) -> None: - """Trigger or schedule an ota update.""" - update_data = self.device.status["update"] - LOGGER.debug("OTA update service - update_data: %s", update_data) + if sleep_period is None: + data = {**entry.data} + data[CONF_SLEEP_PERIOD] = get_rpc_device_sleep_period(device.config) + hass.config_entries.async_update_entry(entry, data=data) - if not update_data["has_update"] and not beta: - LOGGER.warning("No OTA update available for device %s", self.name) - return + _async_rpc_device_setup() - if beta and not update_data.get("beta_version"): - LOGGER.warning( - "No OTA update on beta channel available for device %s", self.name - ) - return - - if update_data["status"] == "updating": - LOGGER.warning("OTA update already in progress for %s", self.name) - return - - new_version = update_data["new_version"] - if beta: - new_version = update_data["beta_version"] - LOGGER.info( - "Start OTA update of device %s from '%s' to '%s'", - self.name, - self.device.firmware_version, - new_version, - ) + if sleep_period == 0: + # Not a sleeping device, finish setup + LOGGER.debug("Setting up online RPC device %s", entry.title) try: - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - result = 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) - - def shutdown(self) -> None: - """Shutdown the wrapper.""" - self.device.shutdown() + await device.initialize() + except DeviceConnectionError as err: + raise ConfigEntryNotReady(repr(err)) from err + except InvalidAuthError as err: + raise ConfigEntryAuthFailed(repr(err)) from err - @callback - def _handle_ha_stop(self, _event: Event) -> None: - """Handle Home Assistant stopping.""" - LOGGER.debug("Stopping BlockDeviceWrapper for %s", self.name) - self.shutdown() - - -class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): - """Rest Wrapper for a Shelly device with Home Assistant specific functions.""" - - def __init__( - self, hass: HomeAssistant, device: BlockDevice, entry: ConfigEntry - ) -> None: - """Initialize the Shelly device wrapper.""" - if ( - device.settings["device"]["type"] - in BATTERY_DEVICES_WITH_PERMANENT_CONNECTION - ): - update_interval = ( - SLEEP_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] - ) - else: - update_interval = REST_SENSORS_UPDATE_INTERVAL - - super().__init__( - hass, - LOGGER, - name=get_block_device_name(device), - update_interval=timedelta(seconds=update_interval), + _async_rpc_device_setup() + elif sleep_period is None or device_entry is None: + # Need to get sleep info or first time sleeping device setup, wait for device + shelly_entry_data.device = device + LOGGER.debug( + "Setup for device %s will resume when device is online", entry.title ) - self.device = device - self.entry = entry + device.subscribe_updates(_async_device_online) + else: + # Restore sensors for sleeping device + LOGGER.debug("Setting up offline block device %s", entry.title) + _async_rpc_device_setup() - async def _async_update_data(self) -> None: - """Fetch data.""" - try: - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - LOGGER.debug("REST update for %s", self.name) - await self.device.update_status() - - if self.device.status["uptime"] > 2 * REST_SENSORS_UPDATE_INTERVAL: - return - old_firmware = self.device.firmware_version - await self.device.update_shelly() - if self.device.firmware_version == old_firmware: - return - device_update_info(self.hass, self.device, self.entry) - except OSError as err: - raise update_coordinator.UpdateFailed("Error fetching data") from err - - @property - def mac(self) -> str: - """Mac address of the device.""" - return cast(str, self.device.settings["device"]["mac"]) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if get_device_entry_gen(entry) == 2: - unload_ok = await hass.config_entries.async_unload_platforms( - entry, RPC_PLATFORMS - ) - if unload_ok: - await hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][RPC].shutdown() - hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) - - return unload_ok + shelly_entry_data = get_entry_data(hass)[entry.entry_id] - device = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].get(DEVICE) - if device is not None: - # If device is present, device wrapper is not setup yet + # If device is present, block/rpc coordinator is not setup yet + device = shelly_entry_data.device + if isinstance(device, RpcDevice): + await device.shutdown() + return True + if isinstance(device, BlockDevice): device.shutdown() return True - platforms = BLOCK_SLEEPING_PLATFORMS - + platforms = RPC_SLEEPING_PLATFORMS if not entry.data.get(CONF_SLEEP_PERIOD): - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][REST] = None - platforms = BLOCK_PLATFORMS - - unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) - if unload_ok: - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][BLOCK].shutdown() - hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) - - return unload_ok - - -def get_block_device_wrapper( - hass: HomeAssistant, device_id: str -) -> BlockDeviceWrapper | None: - """Get a Shelly block device wrapper for the given device id.""" - if not hass.data.get(DOMAIN): - return None - - dev_reg = device_registry.async_get(hass) - if device := dev_reg.async_get(device_id): - for config_entry in device.config_entries: - if not hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry): - continue - - if wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry].get(BLOCK): - return cast(BlockDeviceWrapper, wrapper) - - return None - - -def get_rpc_device_wrapper( - hass: HomeAssistant, device_id: str -) -> RpcDeviceWrapper | None: - """Get a Shelly RPC device wrapper for the given device id.""" - if not hass.data.get(DOMAIN): - return None - - dev_reg = device_registry.async_get(hass) - if device := dev_reg.async_get(device_id): - for config_entry in device.config_entries: - if not hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry): - continue - - if wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry].get(RPC): - return cast(RpcDeviceWrapper, wrapper) + platforms = RPC_PLATFORMS - return None - - -class RpcDeviceWrapper(update_coordinator.DataUpdateCoordinator): - """Wrapper for a Shelly RPC based device with Home Assistant specific functions.""" - - def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, device: RpcDevice - ) -> None: - """Initialize the Shelly device wrapper.""" - self.device_id: str | None = None - - device_name = get_rpc_device_name(device) if device.initialized else entry.title - super().__init__( - hass, - LOGGER, - name=device_name, - update_interval=timedelta(seconds=RPC_RECONNECT_INTERVAL), - ) - self.entry = entry - self.device = device - - self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer( - hass, - LOGGER, - cooldown=ENTRY_RELOAD_COOLDOWN, - immediate=False, - function=self._async_reload_entry, - ) - entry.async_on_unload(self._debounced_reload.async_cancel) - - entry.async_on_unload( - self.async_add_listener(self._async_device_updates_handler) - ) - self._last_event: dict[str, Any] | None = None - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) - ) - - async def _async_reload_entry(self) -> None: - """Reload entry.""" - LOGGER.debug("Reloading entry %s", self.name) - await self.hass.config_entries.async_reload(self.entry.entry_id) - - @callback - def _async_device_updates_handler(self) -> None: - """Handle device updates.""" - if ( - not self.device.initialized - or not self.device.event - or self.device.event == self._last_event + if get_device_entry_gen(entry) == 2: + if unload_ok := await hass.config_entries.async_unload_platforms( + entry, platforms ): - return - - self._last_event = self.device.event - - for event in self.device.event["events"]: - event_type = event.get("event") - if event_type is None: - continue - - if event_type == "config_changed": - LOGGER.info( - "Config for %s changed, reloading entry in %s seconds", - self.name, - ENTRY_RELOAD_COOLDOWN, - ) - self.hass.async_create_task(self._debounced_reload.async_call()) - elif event_type in RPC_INPUTS_EVENTS_TYPES: - self.hass.bus.async_fire( - EVENT_SHELLY_CLICK, - { - ATTR_DEVICE_ID: self.device_id, - ATTR_DEVICE: self.device.hostname, - ATTR_CHANNEL: event["id"] + 1, - ATTR_CLICK_TYPE: event["event"], - ATTR_GENERATION: 2, - }, - ) - - async def _async_update_data(self) -> None: - """Fetch data.""" - if self.device.connected: - return + if shelly_entry_data.rpc: + await shelly_entry_data.rpc.shutdown() + get_entry_data(hass).pop(entry.entry_id) - try: - LOGGER.debug("Reconnecting to Shelly RPC Device - %s", self.name) - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - await self.device.initialize() - device_update_info(self.hass, self.device, self.entry) - except OSError as err: - raise update_coordinator.UpdateFailed("Device disconnected") from err - - @property - def model(self) -> str: - """Model of the device.""" - return cast(str, self.entry.data["model"]) - - @property - def mac(self) -> str: - """Mac address of the device.""" - return cast(str, self.entry.unique_id) - - @property - def sw_version(self) -> str: - """Firmware version of the device.""" - return self.device.firmware_version if self.device.initialized else "" - - def async_setup(self) -> None: - """Set up the wrapper.""" - dev_reg = device_registry.async_get(self.hass) - entry = dev_reg.async_get_or_create( - config_entry_id=self.entry.entry_id, - name=self.name, - connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)}, - manufacturer="Shelly", - model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), - sw_version=self.sw_version, - hw_version=f"gen{self.device.gen} ({self.model})", - configuration_url=f"http://{self.entry.data[CONF_HOST]}", - ) - self.device_id = entry.id - self.device.subscribe_updates(self.async_set_updated_data) - - async def async_trigger_ota_update(self, beta: bool = False) -> None: - """Trigger an ota update.""" - - update_data = self.device.status["sys"]["available_updates"] - LOGGER.debug("OTA update service - update_data: %s", update_data) + return unload_ok - if not bool(update_data) or (not update_data.get("stable") and not beta): - LOGGER.warning("No OTA update available for device %s", self.name) - return + platforms = BLOCK_SLEEPING_PLATFORMS - if beta and not update_data.get(ATTR_BETA): - LOGGER.warning( - "No OTA update on beta channel available for device %s", self.name - ) - return - - new_version = update_data.get("stable", {"version": ""})["version"] - if beta: - new_version = update_data.get(ATTR_BETA, {"version": ""})["version"] - - assert self.device.shelly - LOGGER.info( - "Start OTA update of device %s from '%s' to '%s'", - self.name, - self.device.firmware_version, - new_version, - ) - try: - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - 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("OTA update call successful") - - async def shutdown(self) -> None: - """Shutdown the wrapper.""" - await self.device.shutdown() - - async def _handle_ha_stop(self, _event: Event) -> None: - """Handle Home Assistant stopping.""" - LOGGER.debug("Stopping RpcDeviceWrapper for %s", self.name) - await self.shutdown() - - -class RpcPollingWrapper(update_coordinator.DataUpdateCoordinator): - """Polling Wrapper for a Shelly RPC based device.""" - - def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, device: RpcDevice - ) -> None: - """Initialize the RPC polling coordinator.""" - self.device_id: str | None = None - - device_name = get_rpc_device_name(device) if device.initialized else entry.title - super().__init__( - hass, - LOGGER, - name=device_name, - update_interval=timedelta(seconds=RPC_SENSORS_POLLING_INTERVAL), - ) - self.entry = entry - self.device = device + if not entry.data.get(CONF_SLEEP_PERIOD): + shelly_entry_data.rest = None + platforms = BLOCK_PLATFORMS - async def _async_update_data(self) -> None: - """Fetch data.""" - if not self.device.connected: - raise update_coordinator.UpdateFailed("Device disconnected") + if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): + if shelly_entry_data.block: + shelly_entry_data.block.shutdown() + get_entry_data(hass).pop(entry.entry_id) - try: - LOGGER.debug("Polling Shelly RPC Device - %s", self.name) - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - await self.device.update_status() - except (OSError, aioshelly.exceptions.RPCTimeout) as err: - raise update_coordinator.UpdateFailed("Device disconnected") from err - - @property - def model(self) -> str: - """Model of the device.""" - return cast(str, self.entry.data["model"]) - - @property - def mac(self) -> str: - """Mac address of the device.""" - return cast(str, self.entry.unique_id) + return unload_ok diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index cc6c4494ebba47da0ecc334f4335e4baeee21769..cfacdf85cfde05364df186547dfbd6e19d68969f 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -25,6 +25,7 @@ from .entity import ( ShellyRestAttributeEntity, ShellyRpcAttributeEntity, ShellySleepingBlockAttributeEntity, + ShellySleepingRpcAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rest, async_setup_entry_rpc, @@ -209,9 +210,19 @@ async def async_setup_entry( ) -> None: """Set up sensors for device.""" if get_device_entry_gen(config_entry) == 2: - return async_setup_entry_rpc( - hass, config_entry, async_add_entities, RPC_SENSORS, RpcBinarySensor - ) + if config_entry.data[CONF_SLEEP_PERIOD]: + async_setup_entry_rpc( + hass, + config_entry, + async_add_entities, + RPC_SENSORS, + RpcSleepingBinarySensor, + ) + else: + async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_SENSORS, RpcBinarySensor + ) + return if config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_attribute_entities( @@ -289,3 +300,17 @@ class BlockSleepingBinarySensor(ShellySleepingBlockAttributeEntity, BinarySensor return bool(self.attribute_value) return self.last_state == STATE_ON + + +class RpcSleepingBinarySensor(ShellySleepingRpcAttributeEntity, BinarySensorEntity): + """Represent a RPC sleeping binary sensor entity.""" + + entity_description: RpcBinarySensorDescription + + @property + def is_on(self) -> bool | None: + """Return true if RPC sensor state is on.""" + if self.coordinator.device.initialized: + return bool(self.attribute_value) + + return self.last_state == STATE_ON diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 144b100e8eb3b78525aba6524b7c3b4788511e56..e7989dd94170aa9896f1ef5e962cef86070830b5 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Final, cast +from typing import Final from homeassistant.components.button import ( ButtonDeviceClass, @@ -15,10 +15,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify -from . import BlockDeviceWrapper, RpcDeviceWrapper -from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC, SHELLY_GAS_MODELS +from .const import SHELLY_GAS_MODELS +from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .utils import get_block_device_name, get_device_entry_gen, get_rpc_device_name @@ -42,31 +43,31 @@ BUTTONS: Final = [ name="Reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, - press_action=lambda wrapper: wrapper.device.trigger_reboot(), + press_action=lambda coordinator: coordinator.device.trigger_reboot(), ), ShellyButtonDescription( key="self_test", name="Self Test", icon="mdi:progress-wrench", entity_category=EntityCategory.DIAGNOSTIC, - press_action=lambda wrapper: wrapper.device.trigger_shelly_gas_self_test(), - supported=lambda wrapper: wrapper.device.model in SHELLY_GAS_MODELS, + press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_self_test(), + supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription( key="mute", name="Mute", icon="mdi:volume-mute", entity_category=EntityCategory.CONFIG, - press_action=lambda wrapper: wrapper.device.trigger_shelly_gas_mute(), - supported=lambda wrapper: wrapper.device.model in SHELLY_GAS_MODELS, + press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_mute(), + supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription( key="unmute", name="Unmute", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, - press_action=lambda wrapper: wrapper.device.trigger_shelly_gas_unmute(), - supported=lambda wrapper: wrapper.device.model in SHELLY_GAS_MODELS, + press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_unmute(), + supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ] @@ -77,54 +78,48 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set buttons for device.""" - wrapper: RpcDeviceWrapper | BlockDeviceWrapper | None = None + coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None if get_device_entry_gen(config_entry) == 2: - if rpc_wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][ - config_entry.entry_id - ].get(RPC): - wrapper = cast(RpcDeviceWrapper, rpc_wrapper) + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc else: - if block_wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][ - config_entry.entry_id - ].get(BLOCK): - wrapper = cast(BlockDeviceWrapper, block_wrapper) + coordinator = get_entry_data(hass)[config_entry.entry_id].block - if wrapper is not None: + if coordinator is not None: entities = [] for button in BUTTONS: - if not button.supported(wrapper): + if not button.supported(coordinator): continue - entities.append(ShellyButton(wrapper, button)) + entities.append(ShellyButton(coordinator, button)) async_add_entities(entities) -class ShellyButton(ButtonEntity): +class ShellyButton(CoordinatorEntity, ButtonEntity): """Defines a Shelly base button.""" entity_description: ShellyButtonDescription def __init__( self, - wrapper: RpcDeviceWrapper | BlockDeviceWrapper, + coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator, description: ShellyButtonDescription, ) -> None: """Initialize Shelly button.""" + super().__init__(coordinator) self.entity_description = description - self.wrapper = wrapper - if isinstance(wrapper, RpcDeviceWrapper): - device_name = get_rpc_device_name(wrapper.device) + if isinstance(coordinator, ShellyRpcCoordinator): + device_name = get_rpc_device_name(coordinator.device) else: - device_name = get_block_device_name(wrapper.device) + device_name = get_block_device_name(coordinator.device) self._attr_name = f"{device_name} {description.name}" self._attr_unique_id = slugify(self._attr_name) self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, wrapper.mac)} + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) async def async_press(self) -> None: """Triggers the Shelly button press service.""" - await self.entity_description.press_action(self.wrapper) + await self.entity_description.press_action(self.coordinator) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index e34530d1c8e7beee0b9f810188534a54b07036e8..38ba4a51c9f17073779f51f64923765c6f48a9a1 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -1,13 +1,11 @@ """Climate support for Shelly.""" from __future__ import annotations -import asyncio from collections.abc import Mapping from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.exceptions import AuthRequired -import async_timeout +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, @@ -20,20 +18,15 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers import device_registry, entity_registry, update_coordinator +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import BlockDeviceWrapper -from .const import ( - AIOSHELLY_DEVICE_TIMEOUT_SEC, - BLOCK, - DATA_CONFIG_ENTRY, - DOMAIN, - LOGGER, - SHTRV_01_TEMPERATURE_SETTINGS, -) +from .const import LOGGER, SHTRV_01_TEMPERATURE_SETTINGS +from .coordinator import ShellyBlockCoordinator, get_entry_data from .utils import get_device_entry_gen @@ -47,37 +40,39 @@ async def async_setup_entry( if get_device_entry_gen(config_entry) == 2: return - wrapper: BlockDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ - config_entry.entry_id - ][BLOCK] - - if wrapper.device.initialized: - async_setup_climate_entities(async_add_entities, wrapper) + coordinator = get_entry_data(hass)[config_entry.entry_id].block + assert coordinator + if coordinator.device.initialized: + async_setup_climate_entities(async_add_entities, coordinator) else: - async_restore_climate_entities(hass, config_entry, async_add_entities, wrapper) + async_restore_climate_entities( + hass, config_entry, async_add_entities, coordinator + ) @callback def async_setup_climate_entities( async_add_entities: AddEntitiesCallback, - wrapper: BlockDeviceWrapper, + coordinator: ShellyBlockCoordinator, ) -> None: """Set up online climate devices.""" device_block: Block | None = None sensor_block: Block | None = None - assert wrapper.device.blocks + assert coordinator.device.blocks - for block in wrapper.device.blocks: + for block in coordinator.device.blocks: if block.type == "device": device_block = block if hasattr(block, "targetTemp"): sensor_block = block if sensor_block and device_block: - LOGGER.debug("Setup online climate device %s", wrapper.name) - async_add_entities([BlockSleepingClimate(wrapper, sensor_block, device_block)]) + LOGGER.debug("Setup online climate device %s", coordinator.name) + async_add_entities( + [BlockSleepingClimate(coordinator, sensor_block, device_block)] + ) @callback @@ -85,7 +80,7 @@ def async_restore_climate_entities( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - wrapper: BlockDeviceWrapper, + coordinator: ShellyBlockCoordinator, ) -> None: """Restore sleeping climate devices.""" @@ -99,16 +94,14 @@ def async_restore_climate_entities( if entry.domain != CLIMATE_DOMAIN: continue - LOGGER.debug("Setup sleeping climate device %s", wrapper.name) + LOGGER.debug("Setup sleeping climate device %s", coordinator.name) LOGGER.debug("Found entry %s [%s]", entry.original_name, entry.domain) - async_add_entities([BlockSleepingClimate(wrapper, None, None, entry)]) + async_add_entities([BlockSleepingClimate(coordinator, None, None, entry)]) break class BlockSleepingClimate( - update_coordinator.CoordinatorEntity, - RestoreEntity, - ClimateEntity, + CoordinatorEntity[ShellyBlockCoordinator], RestoreEntity, ClimateEntity ): """Representation of a Shelly climate device.""" @@ -124,16 +117,14 @@ class BlockSleepingClimate( def __init__( self, - wrapper: BlockDeviceWrapper, + coordinator: ShellyBlockCoordinator, sensor_block: Block | None, device_block: Block | None, entry: entity_registry.RegistryEntry | None = None, ) -> None: """Initialize climate.""" + super().__init__(coordinator) - super().__init__(wrapper) - - self.wrapper = wrapper self.block: Block | None = sensor_block self.control_result: dict[str, Any] | None = None self.device_block: Block | None = device_block @@ -143,11 +134,11 @@ class BlockSleepingClimate( self._last_target_temp = 20.0 if self.block is not None and self.device_block is not None: - self._unique_id = f"{self.wrapper.mac}-{self.block.description}" + self._unique_id = f"{self.coordinator.mac}-{self.block.description}" assert self.block.channel self._preset_modes = [ PRESET_NONE, - *wrapper.device.settings["thermostats"][int(self.block.channel)][ + *coordinator.device.settings["thermostats"][int(self.block.channel)][ "schedule_profile_names" ], ] @@ -164,7 +155,7 @@ class BlockSleepingClimate( @property def name(self) -> str: """Name of entity.""" - return self.wrapper.name + return self.coordinator.name @property def target_temperature(self) -> float | None: @@ -185,7 +176,7 @@ class BlockSleepingClimate( """Device availability.""" if self.device_block is not None: return not cast(bool, self.device_block.valveError) - return self.wrapper.last_update_success + return self.coordinator.last_update_success @property def hvac_mode(self) -> HVACMode: @@ -230,7 +221,9 @@ class BlockSleepingClimate( def device_info(self) -> DeviceInfo: """Device info.""" return { - "connections": {(device_registry.CONNECTION_NETWORK_MAC, self.wrapper.mac)} + "connections": { + (device_registry.CONNECTION_NETWORK_MAC, self.coordinator.mac) + } } def _check_is_off(self) -> bool: @@ -244,19 +237,16 @@ class BlockSleepingClimate( """Set block state (HTTP request).""" LOGGER.debug("Setting state for entity %s, state: %s", self.name, kwargs) try: - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - return await self.wrapper.device.http_request( - "get", f"thermostat/{self._channel}", kwargs - ) - except (asyncio.TimeoutError, OSError) as err: - LOGGER.error( - "Setting state for entity %s failed, state: %s, error: %s", - self.name, - kwargs, - repr(err), + return await self.coordinator.device.http_request( + "get", f"thermostat/{self._channel}", kwargs ) - self.wrapper.last_update_success = False - return None + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + f"Setting state for entity {self.name} failed, state: {kwargs}, error: {repr(err)}" + ) from err + except InvalidAuthError: + self.coordinator.entry.async_start_reauth(self.hass) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -309,13 +299,13 @@ class BlockSleepingClimate( @callback def _handle_coordinator_update(self) -> None: """Handle device update.""" - if not self.wrapper.device.initialized: + if not self.coordinator.device.initialized: self.async_write_ha_state() return - assert self.wrapper.device.blocks + assert self.coordinator.device.blocks - for block in self.wrapper.device.blocks: + for block in self.coordinator.device.blocks: if block.type == "device": self.device_block = block if hasattr(block, "targetTemp"): @@ -329,11 +319,11 @@ class BlockSleepingClimate( try: self._preset_modes = [ PRESET_NONE, - *self.wrapper.device.settings["thermostats"][ + *self.coordinator.device.settings["thermostats"][ int(self.block.channel) ]["schedule_profile_names"], ] - except AuthRequired: - self.wrapper.entry.async_start_reauth(self.hass) + except InvalidAuthError: + self.coordinator.entry.async_start_reauth(self.hass) else: self.async_write_ha_state() diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index c2c80b48fc048d4e682af41073bf842d635e29fd..0f6ae9c9da64545ae015fc96b8f5d6929c49fad5 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -1,16 +1,17 @@ """Config flow for Shelly integration.""" from __future__ import annotations -import asyncio from collections.abc import Mapping -from http import HTTPStatus from typing import Any, Final -import aiohttp import aioshelly from aioshelly.block_device import BlockDevice +from aioshelly.exceptions import ( + DeviceConnectionError, + FirmwareUnsupported, + InvalidAuthError, +) from aioshelly.rpc_device import RpcDevice -import async_timeout import voluptuous as vol from homeassistant import config_entries @@ -20,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client -from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, CONF_SLEEP_PERIOD, DOMAIN, LOGGER +from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER from .utils import ( get_block_device_name, get_block_device_sleep_period, @@ -29,12 +30,12 @@ from .utils import ( get_info_gen, get_model_name, get_rpc_device_name, + get_rpc_device_sleep_period, + get_ws_context, ) HOST_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str}) -HTTP_CONNECT_ERRORS: Final = (asyncio.TimeoutError, aiohttp.ClientError) - async def validate_input( hass: HomeAssistant, @@ -52,37 +53,38 @@ async def validate_input( data.get(CONF_PASSWORD), ) - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - if get_info_gen(info) == 2: - rpc_device = await RpcDevice.create( - aiohttp_client.async_get_clientsession(hass), - options, - ) - await rpc_device.shutdown() - assert rpc_device.shelly - - return { - "title": get_rpc_device_name(rpc_device), - CONF_SLEEP_PERIOD: 0, - "model": rpc_device.shelly.get("model"), - "gen": 2, - } - - # Gen1 - coap_context = await get_coap_context(hass) - block_device = await BlockDevice.create( + if get_info_gen(info) == 2: + ws_context = await get_ws_context(hass) + rpc_device = await RpcDevice.create( aiohttp_client.async_get_clientsession(hass), - coap_context, + ws_context, options, ) - block_device.shutdown() + await rpc_device.shutdown() + assert rpc_device.shelly + return { - "title": get_block_device_name(block_device), - CONF_SLEEP_PERIOD: get_block_device_sleep_period(block_device.settings), - "model": block_device.model, - "gen": 1, + "title": get_rpc_device_name(rpc_device), + CONF_SLEEP_PERIOD: get_rpc_device_sleep_period(rpc_device.config), + "model": rpc_device.shelly.get("model"), + "gen": 2, } + # Gen1 + coap_context = await get_coap_context(hass) + block_device = await BlockDevice.create( + aiohttp_client.async_get_clientsession(hass), + coap_context, + options, + ) + block_device.shutdown() + return { + "title": get_block_device_name(block_device), + CONF_SLEEP_PERIOD: get_block_device_sleep_period(block_device.settings), + "model": block_device.model, + "gen": 1, + } + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Shelly.""" @@ -103,9 +105,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host: str = user_input[CONF_HOST] try: self.info = await self._async_get_info(host) - except HTTP_CONNECT_ERRORS: + except DeviceConnectionError: errors["base"] = "cannot_connect" - except aioshelly.exceptions.FirmwareUnsupported: + except FirmwareUnsupported: return self.async_abort(reason="unsupported_firmware") except Exception: # pylint: disable=broad-except LOGGER.exception("Unexpected exception") @@ -121,7 +123,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): device_info = await validate_input( self.hass, self.host, self.info, {} ) - except HTTP_CONNECT_ERRORS: + except DeviceConnectionError: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except LOGGER.exception("Unexpected exception") @@ -155,16 +157,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): device_info = await validate_input( self.hass, self.host, self.info, user_input ) - except aiohttp.ClientResponseError as error: - if error.status == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_auth" - else: - errors["base"] = "cannot_connect" - except aioshelly.exceptions.InvalidAuthError: + except InvalidAuthError: errors["base"] = "invalid_auth" - except HTTP_CONNECT_ERRORS: - errors["base"] = "cannot_connect" - except aioshelly.exceptions.JSONRPCError: + except DeviceConnectionError: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except LOGGER.exception("Unexpected exception") @@ -206,9 +201,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host = discovery_info.host try: self.info = await self._async_get_info(host) - except HTTP_CONNECT_ERRORS: + except DeviceConnectionError: return self.async_abort(reason="cannot_connect") - except aioshelly.exceptions.FirmwareUnsupported: + except FirmwareUnsupported: return self.async_abort(reason="unsupported_firmware") await self.async_set_unique_id(self.info["mac"]) @@ -227,7 +222,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: self.device_info = await validate_input(self.hass, self.host, self.info, {}) - except HTTP_CONNECT_ERRORS: + except DeviceConnectionError: return self.async_abort(reason="cannot_connect") return await self.async_step_confirm_discovery() @@ -280,23 +275,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: info = await self._async_get_info(host) - except ( - asyncio.TimeoutError, - aiohttp.ClientError, - aioshelly.exceptions.FirmwareUnsupported, - ): + except (DeviceConnectionError, InvalidAuthError, FirmwareUnsupported): return self.async_abort(reason="reauth_unsuccessful") if self.entry.data.get("gen", 1) != 1: user_input[CONF_USERNAME] = "admin" try: await validate_input(self.hass, host, info, user_input) - except ( - aiohttp.ClientResponseError, - aioshelly.exceptions.InvalidAuthError, - asyncio.TimeoutError, - aiohttp.ClientError, - ): + except (DeviceConnectionError, InvalidAuthError, FirmwareUnsupported): return self.async_abort(reason="reauth_unsuccessful") else: self.hass.config_entries.async_update_entry( @@ -321,7 +307,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_get_info(self, host: str) -> dict[str, Any]: """Get info from shelly device.""" - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - return await aioshelly.common.get_info( - aiohttp_client.async_get_clientsession(self.hass), host - ) + return await aioshelly.common.get_info( + aiohttp_client.async_get_clientsession(self.hass), host + ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 3dacf2bfd6af49c314d6d93208c36e8ddd4dca9d..39ca515e5ede86cf55728554ec280d58293106d3 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -9,13 +9,7 @@ DOMAIN: Final = "shelly" LOGGER: Logger = getLogger(__package__) -BLOCK: Final = "block" DATA_CONFIG_ENTRY: Final = "config_entry" -DEVICE: Final = "device" -REST: Final = "rest" -RPC: Final = "rpc" -RPC_POLL: Final = "rpc_poll" - CONF_COAP_PORT: Final = "coap_port" DEFAULT_COAP_PORT: Final = 5683 FIRMWARE_PATTERN: Final = re.compile(r"^(\d{8})") @@ -52,18 +46,12 @@ DUAL_MODE_LIGHT_MODELS: Final = ( "SHCB-1", ) -# Used in "_async_update_data" as timeout for polling data from devices. -POLLING_TIMEOUT_SEC: Final = 18 - # Refresh interval for REST sensors REST_SENSORS_UPDATE_INTERVAL: Final = 60 # Refresh interval for RPC polling sensors RPC_SENSORS_POLLING_INTERVAL: Final = 60 -# Timeout used for aioshelly calls -AIOSHELLY_DEVICE_TIMEOUT_SEC: Final = 10 - # Multiplier used to calculate the "update_interval" for sleeping devices. SLEEP_PERIOD_MULTIPLIER: Final = 1.2 CONF_SLEEP_PERIOD: Final = "sleep_period" @@ -154,7 +142,7 @@ SHBLB_1_RGB_EFFECTS: Final = { SHTRV_01_TEMPERATURE_SETTINGS: Final = { "min": 4, "max": 31, - "step": 1, + "step": 0.5, } # Kelvin value for colorTemp @@ -164,9 +152,6 @@ KELVIN_MIN_VALUE_COLOR: Final = 3000 UPTIME_DEVIATION: Final = 5 -# Max RPC switch/input key instances -MAX_RPC_KEY_INSTANCES = 4 - # Time to wait before reloading entry upon device config change ENTRY_RELOAD_COOLDOWN = 60 diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py new file mode 100644 index 0000000000000000000000000000000000000000..23f905b0fd939f3b38f26d43e2ebc64ed5514754 --- /dev/null +++ b/homeassistant/components/shelly/coordinator.py @@ -0,0 +1,560 @@ +"""Coordinators for the Shelly integration.""" +from __future__ import annotations + +from collections.abc import Coroutine +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, cast + +import aioshelly +from aioshelly.block_device import BlockDevice +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +from aioshelly.rpc_device import RpcDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import device_registry +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + ATTR_CHANNEL, + ATTR_CLICK_TYPE, + ATTR_DEVICE, + ATTR_GENERATION, + BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, + CONF_SLEEP_PERIOD, + DATA_CONFIG_ENTRY, + DOMAIN, + DUAL_MODE_LIGHT_MODELS, + ENTRY_RELOAD_COOLDOWN, + EVENT_SHELLY_CLICK, + INPUTS_EVENTS_DICT, + LOGGER, + MODELS_SUPPORTING_LIGHT_EFFECTS, + REST_SENSORS_UPDATE_INTERVAL, + RPC_INPUTS_EVENTS_TYPES, + RPC_RECONNECT_INTERVAL, + RPC_SENSORS_POLLING_INTERVAL, + SHBTN_MODELS, + SLEEP_PERIOD_MULTIPLIER, + UPDATE_PERIOD_MULTIPLIER, +) +from .utils import ( + device_update_info, + get_block_device_name, + get_rpc_device_name, + get_rpc_device_wakeup_period, +) + + +@dataclass +class ShellyEntryData: + """Class for sharing data within a given config entry.""" + + block: ShellyBlockCoordinator | None = None + device: BlockDevice | RpcDevice | None = None + rest: ShellyRestCoordinator | None = None + rpc: ShellyRpcCoordinator | None = None + rpc_poll: ShellyRpcPollingCoordinator | None = None + + +def get_entry_data(hass: HomeAssistant) -> dict[str, ShellyEntryData]: + """Return Shelly entry data for a given config entry.""" + return cast(dict[str, ShellyEntryData], hass.data[DOMAIN][DATA_CONFIG_ENTRY]) + + +class ShellyBlockCoordinator(DataUpdateCoordinator): + """Coordinator for a Shelly block based device.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice + ) -> None: + """Initialize the Shelly block device coordinator.""" + self.device_id: str | None = None + + if sleep_period := entry.data[CONF_SLEEP_PERIOD]: + update_interval = SLEEP_PERIOD_MULTIPLIER * sleep_period + else: + update_interval = ( + UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] + ) + + device_name = ( + get_block_device_name(device) if device.initialized else entry.title + ) + super().__init__( + hass, + LOGGER, + name=device_name, + update_interval=timedelta(seconds=update_interval), + ) + self.hass = hass + self.entry = entry + self.device = device + + self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer( + hass, + LOGGER, + cooldown=ENTRY_RELOAD_COOLDOWN, + immediate=False, + function=self._async_reload_entry, + ) + entry.async_on_unload(self._debounced_reload.async_cancel) + self._last_cfg_changed: int | None = None + self._last_mode: str | None = None + self._last_effect: int | None = None + + entry.async_on_unload( + self.async_add_listener(self._async_device_updates_handler) + ) + self._last_input_events_count: dict = {} + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + ) + + async def _async_reload_entry(self) -> None: + """Reload entry.""" + LOGGER.debug("Reloading entry %s", self.name) + await self.hass.config_entries.async_reload(self.entry.entry_id) + + @callback + def _async_device_updates_handler(self) -> None: + """Handle device updates.""" + if not self.device.initialized: + return + + assert self.device.blocks + + # For buttons which are battery powered - set initial value for last_event_count + if self.model in SHBTN_MODELS and self._last_input_events_count.get(1) is None: + for block in self.device.blocks: + if block.type != "device": + continue + + if len(block.wakeupEvent) == 1 and block.wakeupEvent[0] == "button": + self._last_input_events_count[1] = -1 + + break + + # Check for input events and config change + cfg_changed = 0 + for block in self.device.blocks: + if block.type == "device": + cfg_changed = block.cfgChanged + + # For dual mode bulbs ignore change if it is due to mode/effect change + if self.model in DUAL_MODE_LIGHT_MODELS: + if "mode" in block.sensor_ids: + if self._last_mode != block.mode: + self._last_cfg_changed = None + self._last_mode = block.mode + + if self.model in MODELS_SUPPORTING_LIGHT_EFFECTS: + if "effect" in block.sensor_ids: + if self._last_effect != block.effect: + self._last_cfg_changed = None + self._last_effect = block.effect + + if ( + "inputEvent" not in block.sensor_ids + or "inputEventCnt" not in block.sensor_ids + ): + continue + + channel = int(block.channel or 0) + 1 + event_type = block.inputEvent + last_event_count = self._last_input_events_count.get(channel) + self._last_input_events_count[channel] = block.inputEventCnt + + if ( + last_event_count is None + or last_event_count == block.inputEventCnt + or event_type == "" + ): + continue + + if event_type in INPUTS_EVENTS_DICT: + self.hass.bus.async_fire( + EVENT_SHELLY_CLICK, + { + ATTR_DEVICE_ID: self.device_id, + ATTR_DEVICE: self.device.settings["device"]["hostname"], + ATTR_CHANNEL: channel, + ATTR_CLICK_TYPE: INPUTS_EVENTS_DICT[event_type], + ATTR_GENERATION: 1, + }, + ) + else: + LOGGER.warning( + "Shelly input event %s for device %s is not supported, please open issue", + event_type, + self.name, + ) + + if self._last_cfg_changed is not None and cfg_changed > self._last_cfg_changed: + LOGGER.info( + "Config for %s changed, reloading entry in %s seconds", + self.name, + ENTRY_RELOAD_COOLDOWN, + ) + self.hass.async_create_task(self._debounced_reload.async_call()) + self._last_cfg_changed = cfg_changed + + async def _async_update_data(self) -> None: + """Fetch data.""" + if sleep_period := self.entry.data.get(CONF_SLEEP_PERIOD): + # Sleeping device, no point polling it, just mark it unavailable + raise UpdateFailed( + f"Sleeping device did not update within {sleep_period} seconds interval" + ) + + LOGGER.debug("Polling Shelly Block Device - %s", self.name) + try: + await self.device.update() + except DeviceConnectionError as err: + raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + except InvalidAuthError: + self.entry.async_start_reauth(self.hass) + else: + device_update_info(self.hass, self.device, self.entry) + + @property + def model(self) -> str: + """Model of the device.""" + return cast(str, self.entry.data["model"]) + + @property + def mac(self) -> str: + """Mac address of the device.""" + return cast(str, self.entry.unique_id) + + @property + def sw_version(self) -> str: + """Firmware version of the device.""" + return self.device.firmware_version if self.device.initialized else "" + + def async_setup(self) -> None: + """Set up the coordinator.""" + dev_reg = device_registry.async_get(self.hass) + entry = dev_reg.async_get_or_create( + config_entry_id=self.entry.entry_id, + name=self.name, + connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)}, + manufacturer="Shelly", + model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), + sw_version=self.sw_version, + hw_version=f"gen{self.device.gen} ({self.model})", + configuration_url=f"http://{self.entry.data[CONF_HOST]}", + ) + self.device_id = entry.id + self.device.subscribe_updates(self.async_set_updated_data) + + def shutdown(self) -> None: + """Shutdown the coordinator.""" + self.device.shutdown() + + @callback + def _handle_ha_stop(self, _event: Event) -> None: + """Handle Home Assistant stopping.""" + LOGGER.debug("Stopping block device coordinator for %s", self.name) + self.shutdown() + + +class ShellyRestCoordinator(DataUpdateCoordinator): + """Coordinator for a Shelly REST device.""" + + def __init__( + self, hass: HomeAssistant, device: BlockDevice, entry: ConfigEntry + ) -> None: + """Initialize the Shelly REST device coordinator.""" + if ( + device.settings["device"]["type"] + in BATTERY_DEVICES_WITH_PERMANENT_CONNECTION + ): + update_interval = ( + SLEEP_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] + ) + else: + update_interval = REST_SENSORS_UPDATE_INTERVAL + + super().__init__( + hass, + LOGGER, + name=get_block_device_name(device), + update_interval=timedelta(seconds=update_interval), + ) + self.device = device + self.entry = entry + + async def _async_update_data(self) -> None: + """Fetch data.""" + LOGGER.debug("REST update for %s", self.name) + try: + await self.device.update_status() + + if self.device.status["uptime"] > 2 * REST_SENSORS_UPDATE_INTERVAL: + return + old_firmware = self.device.firmware_version + await self.device.update_shelly() + if self.device.firmware_version == old_firmware: + return + except DeviceConnectionError as err: + raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + except InvalidAuthError: + self.entry.async_start_reauth(self.hass) + else: + device_update_info(self.hass, self.device, self.entry) + + @property + def mac(self) -> str: + """Mac address of the device.""" + return cast(str, self.device.settings["device"]["mac"]) + + +class ShellyRpcCoordinator(DataUpdateCoordinator): + """Coordinator for a Shelly RPC based device.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, device: RpcDevice + ) -> None: + """Initialize the Shelly RPC device coordinator.""" + self.device_id: str | None = None + + if sleep_period := entry.data[CONF_SLEEP_PERIOD]: + update_interval = SLEEP_PERIOD_MULTIPLIER * sleep_period + else: + update_interval = RPC_RECONNECT_INTERVAL + device_name = get_rpc_device_name(device) if device.initialized else entry.title + super().__init__( + hass, + LOGGER, + name=device_name, + update_interval=timedelta(seconds=update_interval), + ) + self.entry = entry + self.device = device + + self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer( + hass, + LOGGER, + cooldown=ENTRY_RELOAD_COOLDOWN, + immediate=False, + function=self._async_reload_entry, + ) + entry.async_on_unload(self._debounced_reload.async_cancel) + + entry.async_on_unload( + self.async_add_listener(self._async_device_updates_handler) + ) + self._last_event: dict[str, Any] | None = None + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + ) + + async def _async_reload_entry(self) -> None: + """Reload entry.""" + LOGGER.debug("Reloading entry %s", self.name) + await self.hass.config_entries.async_reload(self.entry.entry_id) + + def update_sleep_period(self) -> bool: + """Check device sleep period & update if changed.""" + if ( + not self.device.initialized + or not (wakeup_period := get_rpc_device_wakeup_period(self.device.status)) + or wakeup_period == self.entry.data.get(CONF_SLEEP_PERIOD) + ): + return False + + data = {**self.entry.data} + data[CONF_SLEEP_PERIOD] = wakeup_period + self.hass.config_entries.async_update_entry(self.entry, data=data) + + update_interval = SLEEP_PERIOD_MULTIPLIER * wakeup_period + self.update_interval = timedelta(seconds=update_interval) + + return True + + @callback + def _async_device_updates_handler(self) -> None: + """Handle device updates.""" + if ( + not self.device.initialized + or not self.device.event + or self.device.event == self._last_event + ): + return + + self.update_sleep_period() + + self._last_event = self.device.event + + for event in self.device.event["events"]: + event_type = event.get("event") + if event_type is None: + continue + + if event_type == "config_changed": + LOGGER.info( + "Config for %s changed, reloading entry in %s seconds", + self.name, + ENTRY_RELOAD_COOLDOWN, + ) + self.hass.async_create_task(self._debounced_reload.async_call()) + elif event_type in RPC_INPUTS_EVENTS_TYPES: + self.hass.bus.async_fire( + EVENT_SHELLY_CLICK, + { + ATTR_DEVICE_ID: self.device_id, + ATTR_DEVICE: self.device.hostname, + ATTR_CHANNEL: event["id"] + 1, + ATTR_CLICK_TYPE: event["event"], + ATTR_GENERATION: 2, + }, + ) + + async def _async_update_data(self) -> None: + """Fetch data.""" + if self.update_sleep_period(): + return + + if sleep_period := self.entry.data.get(CONF_SLEEP_PERIOD): + # Sleeping device, no point polling it, just mark it unavailable + raise UpdateFailed( + f"Sleeping device did not update within {sleep_period} seconds interval" + ) + if self.device.connected: + return + + LOGGER.debug("Reconnecting to Shelly RPC Device - %s", self.name) + try: + await self.device.initialize() + device_update_info(self.hass, self.device, self.entry) + except DeviceConnectionError as err: + raise UpdateFailed(f"Device disconnected: {repr(err)}") from err + except InvalidAuthError: + self.entry.async_start_reauth(self.hass) + + @property + def model(self) -> str: + """Model of the device.""" + return cast(str, self.entry.data["model"]) + + @property + def mac(self) -> str: + """Mac address of the device.""" + return cast(str, self.entry.unique_id) + + @property + def sw_version(self) -> str: + """Firmware version of the device.""" + return self.device.firmware_version if self.device.initialized else "" + + def async_setup(self) -> None: + """Set up the coordinator.""" + dev_reg = device_registry.async_get(self.hass) + entry = dev_reg.async_get_or_create( + config_entry_id=self.entry.entry_id, + name=self.name, + connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)}, + manufacturer="Shelly", + model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), + sw_version=self.sw_version, + hw_version=f"gen{self.device.gen} ({self.model})", + configuration_url=f"http://{self.entry.data[CONF_HOST]}", + ) + self.device_id = entry.id + self.device.subscribe_updates(self.async_set_updated_data) + + async def shutdown(self) -> None: + """Shutdown the coordinator.""" + await self.device.shutdown() + + async def _handle_ha_stop(self, _event: Event) -> None: + """Handle Home Assistant stopping.""" + LOGGER.debug("Stopping RPC device coordinator for %s", self.name) + await self.shutdown() + + +class ShellyRpcPollingCoordinator(DataUpdateCoordinator): + """Polling coordinator for a Shelly RPC based device.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, device: RpcDevice + ) -> None: + """Initialize the RPC polling coordinator.""" + self.device_id: str | None = None + + device_name = get_rpc_device_name(device) if device.initialized else entry.title + super().__init__( + hass, + LOGGER, + name=device_name, + update_interval=timedelta(seconds=RPC_SENSORS_POLLING_INTERVAL), + ) + self.entry = entry + self.device = device + + async def _async_update_data(self) -> None: + """Fetch data.""" + if not self.device.connected: + raise UpdateFailed("Device disconnected") + + LOGGER.debug("Polling Shelly RPC Device - %s", self.name) + try: + await self.device.update_status() + except (DeviceConnectionError, RpcCallError) as err: + raise UpdateFailed(f"Device disconnected: {repr(err)}") from err + except InvalidAuthError: + self.entry.async_start_reauth(self.hass) + + @property + def model(self) -> str: + """Model of the device.""" + return cast(str, self.entry.data["model"]) + + @property + def mac(self) -> str: + """Mac address of the device.""" + return cast(str, self.entry.unique_id) + + +def get_block_coordinator_by_device_id( + hass: HomeAssistant, device_id: str +) -> ShellyBlockCoordinator | None: + """Get a Shelly block device coordinator for the given device id.""" + if not hass.data.get(DOMAIN): + return None + + dev_reg = device_registry.async_get(hass) + if device := dev_reg.async_get(device_id): + for config_entry in device.config_entries: + if not (entry_data := get_entry_data(hass).get(config_entry)): + continue + + if coordinator := entry_data.block: + return coordinator + + return None + + +def get_rpc_coordinator_by_device_id( + hass: HomeAssistant, device_id: str +) -> ShellyRpcCoordinator | None: + """Get a Shelly RPC device coordinator for the given device id.""" + if not hass.data.get(DOMAIN): + return None + + dev_reg = device_registry.async_get(hass) + if device := dev_reg.async_get(device_id): + for config_entry in device.config_entries: + if not (entry_data := get_entry_data(hass).get(config_entry)): + continue + + if coordinator := entry_data.rpc: + return coordinator + + return None diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index e28fe22a5280c3a63894058a92379bd935823413..66b95a7a7fdfeb83a2a2d6a11e6ca832d6436d74 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -15,8 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BlockDeviceWrapper, RpcDeviceWrapper -from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC +from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ShellyBlockEntity, ShellyRpcEntity from .utils import get_device_entry_gen, get_rpc_key_ids @@ -40,13 +39,14 @@ def async_setup_block_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up cover for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] - blocks = [block for block in wrapper.device.blocks if block.type == "roller"] + coordinator = get_entry_data(hass)[config_entry.entry_id].block + assert coordinator and coordinator.device.blocks + blocks = [block for block in coordinator.device.blocks if block.type == "roller"] if not blocks: return - async_add_entities(BlockShellyCover(wrapper, block) for block in blocks) + async_add_entities(BlockShellyCover(coordinator, block) for block in blocks) @callback @@ -56,14 +56,14 @@ def async_setup_rpc_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entities for RPC device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC] - - cover_key_ids = get_rpc_key_ids(wrapper.device.status, "cover") + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + assert coordinator + cover_key_ids = get_rpc_key_ids(coordinator.device.status, "cover") if not cover_key_ids: return - async_add_entities(RpcShellyCover(wrapper, id_) for id_ in cover_key_ids) + async_add_entities(RpcShellyCover(coordinator, id_) for id_ in cover_key_ids) class BlockShellyCover(ShellyBlockEntity, CoverEntity): @@ -71,14 +71,14 @@ class BlockShellyCover(ShellyBlockEntity, CoverEntity): _attr_device_class = CoverDeviceClass.SHUTTER - def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: + def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: """Initialize block cover.""" - super().__init__(wrapper, block) + super().__init__(coordinator, block) self.control_result: dict[str, Any] | None = None self._attr_supported_features: int = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP ) - if self.wrapper.device.settings["rollers"][0]["positioning"]: + if self.coordinator.device.settings["rollers"][0]["positioning"]: self._attr_supported_features |= CoverEntityFeature.SET_POSITION @property @@ -147,9 +147,9 @@ class RpcShellyCover(ShellyRpcEntity, CoverEntity): _attr_device_class = CoverDeviceClass.SHUTTER - def __init__(self, wrapper: RpcDeviceWrapper, id_: int) -> None: + def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: """Initialize rpc cover.""" - super().__init__(wrapper, f"cover:{id_}") + super().__init__(coordinator, f"cover:{id_}") self._id = id_ self._attr_supported_features: int = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index fe253ebacb6e1ee15054c8528e7f7e9a0e02433d..1f41483efc06b012888b99854978d05edb2b606b 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -22,7 +22,6 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import get_block_device_wrapper, get_rpc_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, @@ -34,6 +33,10 @@ from .const import ( RPC_INPUTS_EVENTS_TYPES, SHBTN_MODELS, ) +from .coordinator import ( + get_block_coordinator_by_device_id, + get_rpc_coordinator_by_device_id, +) from .utils import ( get_block_input_triggers, get_rpc_input_triggers, @@ -78,23 +81,25 @@ async def async_validate_trigger_config( trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) if config[CONF_TYPE] in RPC_INPUTS_EVENTS_TYPES: - rpc_wrapper = get_rpc_device_wrapper(hass, config[CONF_DEVICE_ID]) - if not rpc_wrapper or not rpc_wrapper.device.initialized: + rpc_coordinator = get_rpc_coordinator_by_device_id(hass, config[CONF_DEVICE_ID]) + if not rpc_coordinator or not rpc_coordinator.device.initialized: return config - input_triggers = get_rpc_input_triggers(rpc_wrapper.device) + input_triggers = get_rpc_input_triggers(rpc_coordinator.device) if trigger in input_triggers: return config elif config[CONF_TYPE] in BLOCK_INPUTS_EVENTS_TYPES: - block_wrapper = get_block_device_wrapper(hass, config[CONF_DEVICE_ID]) - if not block_wrapper or not block_wrapper.device.initialized: + block_coordinator = get_block_coordinator_by_device_id( + hass, config[CONF_DEVICE_ID] + ) + if not block_coordinator or not block_coordinator.device.initialized: return config - assert block_wrapper.device.blocks + assert block_coordinator.device.blocks - for block in block_wrapper.device.blocks: - input_triggers = get_block_input_triggers(block_wrapper.device, block) + for block in block_coordinator.device.blocks: + input_triggers = get_block_input_triggers(block_coordinator.device, block) if trigger in input_triggers: return config @@ -109,24 +114,24 @@ async def async_get_triggers( """List device triggers for Shelly devices.""" triggers: list[dict[str, str]] = [] - if rpc_wrapper := get_rpc_device_wrapper(hass, device_id): - input_triggers = get_rpc_input_triggers(rpc_wrapper.device) + if rpc_coordinator := get_rpc_coordinator_by_device_id(hass, device_id): + input_triggers = get_rpc_input_triggers(rpc_coordinator.device) append_input_triggers(triggers, input_triggers, device_id) return triggers - if block_wrapper := get_block_device_wrapper(hass, device_id): - if block_wrapper.model in SHBTN_MODELS: + if block_coordinator := get_block_coordinator_by_device_id(hass, device_id): + if block_coordinator.model in SHBTN_MODELS: input_triggers = get_shbtn_input_triggers() append_input_triggers(triggers, input_triggers, device_id) return triggers - if not block_wrapper.device.initialized: + if not block_coordinator.device.initialized: return triggers - assert block_wrapper.device.blocks + assert block_coordinator.device.blocks - for block in block_wrapper.device.blocks: - input_triggers = get_block_input_triggers(block_wrapper.device, block) + for block in block_coordinator.device.blocks: + input_triggers = get_block_input_triggers(block_coordinator.device, block) append_input_triggers(triggers, input_triggers, device_id) return triggers diff --git a/homeassistant/components/shelly/diagnostics.py b/homeassistant/components/shelly/diagnostics.py index 47dc18d377b232fa3865a84fcfbada7d04857f6f..6e5f8d139a2228b5266b1e6c11ce05fcd0a5ce2d 100644 --- a/homeassistant/components/shelly/diagnostics.py +++ b/homeassistant/components/shelly/diagnostics.py @@ -6,8 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import BlockDeviceWrapper, RpcDeviceWrapper -from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC +from .coordinator import get_entry_data TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} @@ -16,26 +15,27 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict: """Return diagnostics for a config entry.""" - data: dict = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] + shelly_entry_data = get_entry_data(hass)[entry.entry_id] device_settings: str | dict = "not initialized" device_status: str | dict = "not initialized" - if BLOCK in data: - block_wrapper: BlockDeviceWrapper = data[BLOCK] + if shelly_entry_data.block: + block_coordinator = shelly_entry_data.block + assert block_coordinator device_info = { - "name": block_wrapper.name, - "model": block_wrapper.model, - "sw_version": block_wrapper.sw_version, + "name": block_coordinator.name, + "model": block_coordinator.model, + "sw_version": block_coordinator.sw_version, } - if block_wrapper.device.initialized: + if block_coordinator.device.initialized: device_settings = { k: v - for k, v in block_wrapper.device.settings.items() + for k, v in block_coordinator.device.settings.items() if k in ["cloud", "coiot"] } device_status = { k: v - for k, v in block_wrapper.device.status.items() + for k, v in block_coordinator.device.status.items() if k in [ "update", @@ -51,19 +51,20 @@ async def async_get_config_entry_diagnostics( ] } else: - rpc_wrapper: RpcDeviceWrapper = data[RPC] + rpc_coordinator = shelly_entry_data.rpc + assert rpc_coordinator device_info = { - "name": rpc_wrapper.name, - "model": rpc_wrapper.model, - "sw_version": rpc_wrapper.sw_version, + "name": rpc_coordinator.name, + "model": rpc_coordinator.model, + "sw_version": rpc_coordinator.sw_version, } - if rpc_wrapper.device.initialized: + if rpc_coordinator.device.initialized: device_settings = { - k: v for k, v in rpc_wrapper.device.config.items() if k in ["cloud"] + k: v for k, v in rpc_coordinator.device.config.items() if k in ["cloud"] } device_status = { k: v - for k, v in rpc_wrapper.device.status.items() + for k, v in rpc_coordinator.device.status.items() if k in ["sys", "wifi"] } diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index a38bef54bea86e4d89418af1992908fd57729c00..fd92ea41408393bde475c6000eac86b394c7f0ab 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -1,42 +1,30 @@ """Shelly entity helper.""" from __future__ import annotations -import asyncio from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -import async_timeout +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - device_registry, - entity, - entity_registry, - update_coordinator, -) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry, entity, entity_registry from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType - -from . import ( - BlockDeviceWrapper, - RpcDeviceWrapper, - RpcPollingWrapper, - ShellyDeviceRestWrapper, -) -from .const import ( - AIOSHELLY_DEVICE_TIMEOUT_SEC, - BLOCK, - DATA_CONFIG_ENTRY, - DOMAIN, - LOGGER, - REST, - RPC, - RPC_POLL, +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_SLEEP_PERIOD, LOGGER +from .coordinator import ( + ShellyBlockCoordinator, + ShellyRpcCoordinator, + ShellyRpcPollingCoordinator, + get_entry_data, ) from .utils import ( async_remove_shelly_entity, @@ -53,25 +41,21 @@ def async_setup_entry_attribute_entities( async_add_entities: AddEntitiesCallback, sensors: Mapping[tuple[str, str], BlockEntityDescription], sensor_class: Callable, - description_class: Callable[ - [entity_registry.RegistryEntry], BlockEntityDescription - ], + description_class: Callable[[RegistryEntry], BlockEntityDescription], ) -> None: """Set up entities for attributes.""" - wrapper: BlockDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ - config_entry.entry_id - ][BLOCK] - - if wrapper.device.initialized: + coordinator = get_entry_data(hass)[config_entry.entry_id].block + assert coordinator + if coordinator.device.initialized: async_setup_block_attribute_entities( - hass, async_add_entities, wrapper, sensors, sensor_class + hass, async_add_entities, coordinator, sensors, sensor_class ) else: async_restore_block_attribute_entities( hass, config_entry, async_add_entities, - wrapper, + coordinator, sensors, sensor_class, description_class, @@ -82,16 +66,16 @@ def async_setup_entry_attribute_entities( def async_setup_block_attribute_entities( hass: HomeAssistant, async_add_entities: AddEntitiesCallback, - wrapper: BlockDeviceWrapper, + coordinator: ShellyBlockCoordinator, sensors: Mapping[tuple[str, str], BlockEntityDescription], sensor_class: Callable, ) -> None: """Set up entities for block attributes.""" blocks = [] - assert wrapper.device.blocks + assert coordinator.device.blocks - for block in wrapper.device.blocks: + for block in coordinator.device.blocks: for sensor_id in block.sensor_ids: description = sensors.get((block.type, sensor_id)) if description is None: @@ -103,10 +87,10 @@ def async_setup_block_attribute_entities( # Filter and remove entities that according to settings should not create an entity if description.removal_condition and description.removal_condition( - wrapper.device.settings, block + coordinator.device.settings, block ): domain = sensor_class.__module__.split(".")[-1] - unique_id = f"{wrapper.mac}-{block.description}-{sensor_id}" + unique_id = f"{coordinator.mac}-{block.description}-{sensor_id}" async_remove_shelly_entity(hass, domain, unique_id) else: blocks.append((block, sensor_id, description)) @@ -116,7 +100,7 @@ def async_setup_block_attribute_entities( async_add_entities( [ - sensor_class(wrapper, block, sensor_id, description) + sensor_class(coordinator, block, sensor_id, description) for block, sensor_id, description in blocks ] ) @@ -127,12 +111,10 @@ def async_restore_block_attribute_entities( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - wrapper: BlockDeviceWrapper, + coordinator: ShellyBlockCoordinator, sensors: Mapping[tuple[str, str], BlockEntityDescription], sensor_class: Callable, - description_class: Callable[ - [entity_registry.RegistryEntry], BlockEntityDescription - ], + description_class: Callable[[RegistryEntry], BlockEntityDescription], ) -> None: """Restore block attributes entities.""" entities = [] @@ -152,7 +134,7 @@ def async_restore_block_attribute_entities( description = description_class(entry) entities.append( - sensor_class(wrapper, None, attribute, description, entry, sensors) + sensor_class(coordinator, None, attribute, description, entry, sensors) ) if not entities: @@ -169,41 +151,105 @@ def async_setup_entry_rpc( sensors: Mapping[str, RpcEntityDescription], sensor_class: Callable, ) -> None: - """Set up entities for REST sensors.""" - wrapper: RpcDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ - config_entry.entry_id - ][RPC] + """Set up entities for RPC sensors.""" + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + assert coordinator + + if coordinator.device.initialized: + async_setup_rpc_attribute_entities( + hass, config_entry, async_add_entities, sensors, sensor_class + ) + else: + async_restore_rpc_attribute_entities( + hass, config_entry, async_add_entities, coordinator, sensors, sensor_class + ) + + +@callback +def async_setup_rpc_attribute_entities( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + sensors: Mapping[str, RpcEntityDescription], + sensor_class: Callable, +) -> None: + """Set up entities for RPC attributes.""" + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + assert coordinator - polling_wrapper: RpcPollingWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ - config_entry.entry_id - ][RPC_POLL] + if not (sleep_period := config_entry.data[CONF_SLEEP_PERIOD]): + polling_coordinator = get_entry_data(hass)[config_entry.entry_id].rpc_poll + assert polling_coordinator entities = [] for sensor_id in sensors: description = sensors[sensor_id] - key_instances = get_rpc_key_instances(wrapper.device.status, description.key) + key_instances = get_rpc_key_instances( + coordinator.device.status, description.key + ) for key in key_instances: # Filter non-existing sensors - if description.sub_key not in wrapper.device.status[ + if description.sub_key not in coordinator.device.status[ key - ] and not description.supported(wrapper.device.status[key]): + ] and not description.supported(coordinator.device.status[key]): continue # Filter and remove entities that according to settings/status should not create an entity if description.removal_condition and description.removal_condition( - wrapper.device.config, wrapper.device.status, key + coordinator.device.config, coordinator.device.status, key ): domain = sensor_class.__module__.split(".")[-1] - unique_id = f"{wrapper.mac}-{key}-{sensor_id}" + unique_id = f"{coordinator.mac}-{key}-{sensor_id}" async_remove_shelly_entity(hass, domain, unique_id) else: - if description.use_polling_wrapper: + if description.use_polling_coordinator: + if not sleep_period: + entities.append( + sensor_class( + polling_coordinator, key, sensor_id, description + ) + ) + else: entities.append( - sensor_class(polling_wrapper, key, sensor_id, description) + sensor_class(coordinator, key, sensor_id, description) ) - else: - entities.append(sensor_class(wrapper, key, sensor_id, description)) + if not entities: + return + + async_add_entities(entities) + + +@callback +def async_restore_rpc_attribute_entities( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + coordinator: ShellyRpcCoordinator, + sensors: Mapping[str, RpcEntityDescription], + sensor_class: Callable, +) -> None: + """Restore block attributes entities.""" + entities = [] + + ent_reg = entity_registry.async_get(hass) + entries = entity_registry.async_entries_for_config_entry( + ent_reg, config_entry.entry_id + ) + + domain = sensor_class.__module__.split(".")[-1] + + for entry in entries: + if entry.domain != domain: + continue + + key = entry.unique_id.split("-")[-2] + attribute = entry.unique_id.split("-")[-1] + + if description := sensors.get(attribute): + entities.append( + sensor_class(coordinator, key, attribute, description, entry) + ) if not entities: return @@ -220,15 +266,13 @@ def async_setup_entry_rest( sensor_class: Callable, ) -> None: """Set up entities for REST sensors.""" - wrapper: ShellyDeviceRestWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ - config_entry.entry_id - ][REST] - + coordinator = get_entry_data(hass)[config_entry.entry_id].rest + assert coordinator entities = [] for sensor_id in sensors: description = sensors.get(sensor_id) - if not wrapper.device.settings.get("sleep_mode"): + if not coordinator.device.settings.get("sleep_mode"): entities.append((sensor_id, description)) if not entities: @@ -236,7 +280,7 @@ def async_setup_entry_rest( async_add_entities( [ - sensor_class(wrapper, sensor_id, description) + sensor_class(coordinator, sensor_id, description) for sensor_id, description in entities ] ) @@ -270,7 +314,7 @@ class RpcEntityDescription(EntityDescription, RpcEntityRequiredKeysMixin): available: Callable[[dict], bool] | None = None removal_condition: Callable[[dict, dict, str], bool] | None = None extra_state_attributes: Callable[[dict, dict], dict | None] | None = None - use_polling_wrapper: bool = False + use_polling_coordinator: bool = False supported: Callable = lambda _: False @@ -282,32 +326,32 @@ class RestEntityDescription(EntityDescription): extra_state_attributes: Callable[[dict], dict | None] | None = None -class ShellyBlockEntity(entity.Entity): +class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): """Helper class to represent a block entity.""" - def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: + def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: """Initialize Shelly entity.""" - self.wrapper = wrapper + super().__init__(coordinator) self.block = block - self._attr_name = get_block_entity_name(wrapper.device, block) + self._attr_name = get_block_entity_name(coordinator.device, block) self._attr_should_poll = False self._attr_device_info = DeviceInfo( - connections={(device_registry.CONNECTION_NETWORK_MAC, wrapper.mac)} + connections={(device_registry.CONNECTION_NETWORK_MAC, coordinator.mac)} ) - self._attr_unique_id = f"{wrapper.mac}-{block.description}" + self._attr_unique_id = f"{coordinator.mac}-{block.description}" @property def available(self) -> bool: """Available.""" - return self.wrapper.last_update_success + return self.coordinator.last_update_success async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" - self.async_on_remove(self.wrapper.async_add_listener(self._update_callback)) + self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) async def async_update(self) -> None: """Update entity with latest info.""" - await self.wrapper.async_request_refresh() + await self.coordinator.async_request_refresh() @callback def _update_callback(self) -> None: @@ -318,17 +362,14 @@ class ShellyBlockEntity(entity.Entity): """Set block state (HTTP request).""" LOGGER.debug("Setting state for entity %s, state: %s", self.name, kwargs) try: - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - return await self.block.set_state(**kwargs) - except (asyncio.TimeoutError, OSError) as err: - LOGGER.error( - "Setting state for entity %s failed, state: %s, error: %s", - self.name, - kwargs, - repr(err), - ) - self.wrapper.last_update_success = False - return None + return await self.block.set_state(**kwargs) + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + f"Setting state for entity {self.name} failed, state: {kwargs}, error: {repr(err)}" + ) from err + except InvalidAuthError: + self.coordinator.entry.async_start_reauth(self.hass) class ShellyRpcEntity(entity.Entity): @@ -336,36 +377,36 @@ class ShellyRpcEntity(entity.Entity): def __init__( self, - wrapper: RpcDeviceWrapper | RpcPollingWrapper, + coordinator: ShellyRpcCoordinator | ShellyRpcPollingCoordinator, key: str, ) -> None: """Initialize Shelly entity.""" - self.wrapper = wrapper + self.coordinator = coordinator self.key = key self._attr_should_poll = False self._attr_device_info = { - "connections": {(device_registry.CONNECTION_NETWORK_MAC, wrapper.mac)} + "connections": {(device_registry.CONNECTION_NETWORK_MAC, coordinator.mac)} } - self._attr_unique_id = f"{wrapper.mac}-{key}" - self._attr_name = get_rpc_entity_name(wrapper.device, key) + self._attr_unique_id = f"{coordinator.mac}-{key}" + self._attr_name = get_rpc_entity_name(coordinator.device, key) @property def available(self) -> bool: """Available.""" - return self.wrapper.device.connected + return self.coordinator.last_update_success @property def status(self) -> dict: """Device status by entity key.""" - return cast(dict, self.wrapper.device.status[self.key]) + return cast(dict, self.coordinator.device.status[self.key]) async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" - self.async_on_remove(self.wrapper.async_add_listener(self._update_callback)) + self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) async def async_update(self) -> None: """Update entity with latest info.""" - await self.wrapper.async_request_refresh() + await self.coordinator.async_request_refresh() @callback def _update_callback(self) -> None: @@ -381,18 +422,18 @@ class ShellyRpcEntity(entity.Entity): params, ) try: - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - return await self.wrapper.device.call_rpc(method, params) - except asyncio.TimeoutError as err: - LOGGER.error( - "Call RPC for entity %s failed, method: %s, params: %s, error: %s", - self.name, - method, - params, - repr(err), - ) - self.wrapper.last_update_success = False - return None + return await self.coordinator.device.call_rpc(method, params) + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + f"Call RPC for {self.name} connection error, method: {method}, params: {params}, error: {repr(err)}" + ) from err + except RpcCallError as err: + raise HomeAssistantError( + f"Call RPC for {self.name} request error, method: {method}, params: {params}, error: {repr(err)}" + ) from err + except InvalidAuthError: + self.coordinator.entry.async_start_reauth(self.hass) class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): @@ -402,18 +443,20 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): def __init__( self, - wrapper: BlockDeviceWrapper, + coordinator: ShellyBlockCoordinator, block: Block, attribute: str, description: BlockEntityDescription, ) -> None: """Initialize sensor.""" - super().__init__(wrapper, block) + super().__init__(coordinator, block) self.attribute = attribute self.entity_description = description self._attr_unique_id: str = f"{super().unique_id}-{self.attribute}" - self._attr_name = get_block_entity_name(wrapper.device, block, description.name) + self._attr_name = get_block_entity_name( + coordinator.device, block, description.name + ) @property def attribute_value(self) -> StateType: @@ -442,40 +485,42 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): return self.entity_description.extra_state_attributes(self.block) -class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): +class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]): """Class to load info from REST.""" entity_description: RestEntityDescription def __init__( self, - wrapper: BlockDeviceWrapper, + coordinator: ShellyBlockCoordinator, attribute: str, description: RestEntityDescription, ) -> None: """Initialize sensor.""" - super().__init__(wrapper) - self.wrapper = wrapper + super().__init__(coordinator) + self.block_coordinator = coordinator self.attribute = attribute self.entity_description = description - self._attr_name = get_block_entity_name(wrapper.device, None, description.name) - self._attr_unique_id = f"{wrapper.mac}-{attribute}" + self._attr_name = get_block_entity_name( + coordinator.device, None, description.name + ) + self._attr_unique_id = f"{coordinator.mac}-{attribute}" self._attr_device_info = DeviceInfo( - connections={(device_registry.CONNECTION_NETWORK_MAC, wrapper.mac)} + connections={(device_registry.CONNECTION_NETWORK_MAC, coordinator.mac)} ) self._last_value = None @property def available(self) -> bool: """Available.""" - return self.wrapper.last_update_success + return self.block_coordinator.last_update_success @property def attribute_value(self) -> StateType: """Value of sensor.""" if callable(self.entity_description.value): self._last_value = self.entity_description.value( - self.wrapper.device.status, self._last_value + self.block_coordinator.device.status, self._last_value ) return self._last_value @@ -486,7 +531,7 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): return None return self.entity_description.extra_state_attributes( - self.wrapper.device.status + self.block_coordinator.device.status ) @@ -497,18 +542,18 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, entity.Entity): def __init__( self, - wrapper: RpcDeviceWrapper, + coordinator: ShellyRpcCoordinator, key: str, attribute: str, description: RpcEntityDescription, ) -> None: """Initialize sensor.""" - super().__init__(wrapper, key) + super().__init__(coordinator, key) self.attribute = attribute self.entity_description = description self._attr_unique_id = f"{super().unique_id}-{attribute}" - self._attr_name = get_rpc_entity_name(wrapper.device, key, description.name) + self._attr_name = get_rpc_entity_name(coordinator.device, key, description.name) self._last_value = None @property @@ -516,13 +561,13 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, entity.Entity): """Value of sensor.""" if callable(self.entity_description.value): self._last_value = self.entity_description.value( - self.wrapper.device.status[self.key].get( + self.coordinator.device.status[self.key].get( self.entity_description.sub_key ), self._last_value, ) else: - self._last_value = self.wrapper.device.status[self.key][ + self._last_value = self.coordinator.device.status[self.key][ self.entity_description.sub_key ] @@ -537,7 +582,7 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, entity.Entity): return available return self.entity_description.available( - self.wrapper.device.status[self.key][self.entity_description.sub_key] + self.coordinator.device.status[self.key][self.entity_description.sub_key] ) @property @@ -546,11 +591,11 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, entity.Entity): if self.entity_description.extra_state_attributes is None: return None - assert self.wrapper.device.shelly + assert self.coordinator.device.shelly return self.entity_description.extra_state_attributes( - self.wrapper.device.status[self.key][self.entity_description.sub_key], - self.wrapper.device.shelly, + self.coordinator.device.status[self.key][self.entity_description.sub_key], + self.coordinator.device.shelly, ) @@ -560,30 +605,32 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti # pylint: disable=super-init-not-called def __init__( self, - wrapper: BlockDeviceWrapper, + coordinator: ShellyBlockCoordinator, block: Block | None, attribute: str, description: BlockEntityDescription, - entry: entity_registry.RegistryEntry | None = None, + entry: RegistryEntry | None = None, sensors: Mapping[tuple[str, str], BlockEntityDescription] | None = None, ) -> None: """Initialize the sleeping sensor.""" self.sensors = sensors self.last_state: StateType = None - self.wrapper = wrapper + self.coordinator = coordinator self.attribute = attribute self.block: Block | None = block # type: ignore[assignment] self.entity_description = description self._attr_should_poll = False self._attr_device_info = DeviceInfo( - connections={(device_registry.CONNECTION_NETWORK_MAC, wrapper.mac)} + connections={(device_registry.CONNECTION_NETWORK_MAC, coordinator.mac)} ) if block is not None: - self._attr_unique_id = f"{self.wrapper.mac}-{block.description}-{attribute}" + self._attr_unique_id = ( + f"{self.coordinator.mac}-{block.description}-{attribute}" + ) self._attr_name = get_block_entity_name( - self.wrapper.device, block, self.entity_description.name + self.coordinator.device, block, self.entity_description.name ) elif entry is not None: self._attr_unique_id = entry.unique_id @@ -603,7 +650,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti """Handle device update.""" if ( self.block is not None - or not self.wrapper.device.initialized + or not self.coordinator.device.initialized or self.sensors is None ): super()._update_callback() @@ -611,9 +658,9 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti _, entity_block, entity_sensor = self._attr_unique_id.split("-") - assert self.wrapper.device.blocks + assert self.coordinator.device.blocks - for block in self.wrapper.device.blocks: + for block in self.coordinator.device.blocks: if block.description != entity_block: continue @@ -631,3 +678,50 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti LOGGER.debug("Entity %s attached to block", self.name) super()._update_callback() return + + +class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity, RestoreEntity): + """Helper class to represent a sleeping rpc attribute.""" + + entity_description: RpcEntityDescription + + # pylint: disable=super-init-not-called + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcEntityDescription, + entry: RegistryEntry | None = None, + ) -> None: + """Initialize the sleeping sensor.""" + self.last_state: StateType = None + self.coordinator = coordinator + self.key = key + self.attribute = attribute + self.entity_description = description + + self._attr_should_poll = False + self._attr_device_info = DeviceInfo( + connections={(device_registry.CONNECTION_NETWORK_MAC, coordinator.mac)} + ) + self._attr_unique_id = ( + self._attr_unique_id + ) = f"{coordinator.mac}-{key}-{attribute}" + self._last_value = None + + if coordinator.device.initialized: + self._attr_name = get_rpc_entity_name( + coordinator.device, key, description.name + ) + elif entry is not None: + self._attr_name = cast(str, entry.original_name) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + + if last_state is not None: + self.last_state = last_state.state diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index b75e1ad237796393f9f721370db6658ac0b93fdb..dda9a41bb897f8f30b2fe237a37fabd571b004c7 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -7,7 +7,7 @@ from aioshelly.block_device import Block from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, @@ -20,16 +20,8 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, - color_temperature_mired_to_kelvin, -) -from . import BlockDeviceWrapper, RpcDeviceWrapper from .const import ( - BLOCK, - DATA_CONFIG_ENTRY, - DOMAIN, DUAL_MODE_LIGHT_MODELS, FIRMWARE_PATTERN, KELVIN_MAX_VALUE, @@ -40,10 +32,10 @@ from .const import ( MAX_TRANSITION_TIME, MODELS_SUPPORTING_LIGHT_TRANSITION, RGBW_MODELS, - RPC, SHBLB_1_RGB_EFFECTS, STANDARD_RGB_EFFECTS, ) +from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ShellyBlockEntity, ShellyRpcEntity from .utils import ( async_remove_shelly_entity, @@ -53,10 +45,6 @@ from .utils import ( is_rpc_channel_type_light, ) -MIRED_MAX_VALUE_WHITE = color_temperature_kelvin_to_mired(KELVIN_MIN_VALUE_WHITE) -MIRED_MIN_VALUE = color_temperature_kelvin_to_mired(KELVIN_MAX_VALUE) -MIRED_MAX_VALUE_COLOR = color_temperature_kelvin_to_mired(KELVIN_MIN_VALUE_COLOR) - async def async_setup_entry( hass: HomeAssistant, @@ -77,28 +65,28 @@ def async_setup_block_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entities for block device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] - + coordinator = get_entry_data(hass)[config_entry.entry_id].block + assert coordinator blocks = [] - assert wrapper.device.blocks - for block in wrapper.device.blocks: + assert coordinator.device.blocks + for block in coordinator.device.blocks: if block.type == "light": blocks.append(block) elif block.type == "relay": if not is_block_channel_type_light( - wrapper.device.settings, int(block.channel) + coordinator.device.settings, int(block.channel) ): continue blocks.append(block) - assert wrapper.device.shelly - unique_id = f"{wrapper.mac}-{block.type}_{block.channel}" + assert coordinator.device.shelly + unique_id = f"{coordinator.mac}-{block.type}_{block.channel}" async_remove_shelly_entity(hass, "switch", unique_id) if not blocks: return - async_add_entities(BlockShellyLight(wrapper, block) for block in blocks) + async_add_entities(BlockShellyLight(coordinator, block) for block in blocks) @callback @@ -108,22 +96,23 @@ def async_setup_rpc_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entities for RPC device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC] - switch_key_ids = get_rpc_key_ids(wrapper.device.status, "switch") + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + assert coordinator + switch_key_ids = get_rpc_key_ids(coordinator.device.status, "switch") switch_ids = [] for id_ in switch_key_ids: - if not is_rpc_channel_type_light(wrapper.device.config, id_): + if not is_rpc_channel_type_light(coordinator.device.config, id_): continue switch_ids.append(id_) - unique_id = f"{wrapper.mac}-switch:{id_}" + unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "switch", unique_id) if not switch_ids: return - async_add_entities(RpcShellyLight(wrapper, id_) for id_ in switch_ids) + async_add_entities(RpcShellyLight(coordinator, id_) for id_ in switch_ids) class BlockShellyLight(ShellyBlockEntity, LightEntity): @@ -131,20 +120,17 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): _attr_supported_color_modes: set[str] - def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: + def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: """Initialize light.""" - super().__init__(wrapper, block) + super().__init__(coordinator, block) self.control_result: dict[str, Any] | None = None self._attr_supported_color_modes = set() - self._attr_min_mireds = MIRED_MIN_VALUE - self._min_kelvin: int = KELVIN_MIN_VALUE_WHITE - self._attr_max_mireds = MIRED_MAX_VALUE_WHITE - self._max_kelvin: int = KELVIN_MAX_VALUE + self._attr_min_color_temp_kelvin = KELVIN_MIN_VALUE_WHITE + self._attr_max_color_temp_kelvin = KELVIN_MAX_VALUE if hasattr(block, "red") and hasattr(block, "green") and hasattr(block, "blue"): - self._attr_max_mireds = MIRED_MAX_VALUE_COLOR - self._min_kelvin = KELVIN_MIN_VALUE_COLOR - if wrapper.model in RGBW_MODELS: + self._attr_min_color_temp_kelvin = KELVIN_MIN_VALUE_COLOR + if coordinator.model in RGBW_MODELS: self._attr_supported_color_modes.add(ColorMode.RGBW) else: self._attr_supported_color_modes.add(ColorMode.RGB) @@ -161,8 +147,8 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): if hasattr(block, "effect"): self._attr_supported_features |= LightEntityFeature.EFFECT - if wrapper.model in MODELS_SUPPORTING_LIGHT_TRANSITION: - match = FIRMWARE_PATTERN.search(wrapper.device.settings.get("fw", "")) + if coordinator.model in MODELS_SUPPORTING_LIGHT_TRANSITION: + match = FIRMWARE_PATTERN.search(coordinator.device.settings.get("fw", "")) if ( match is not None and int(match[0]) >= LIGHT_TRANSITION_MIN_FIRMWARE_DATE @@ -215,7 +201,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): def color_mode(self) -> ColorMode: """Return the color mode of the light.""" if self.mode == "color": - if self.wrapper.model in RGBW_MODELS: + if self.coordinator.model in RGBW_MODELS: return ColorMode.RGBW return ColorMode.RGB @@ -251,24 +237,21 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): return (*self.rgb_color, white) @property - def color_temp(self) -> int: - """Return the CT color value in mireds.""" + def color_temp_kelvin(self) -> int: + """Return the CT color value in kelvin.""" + color_temp = cast(int, self.block.colorTemp) if self.control_result: color_temp = self.control_result["temp"] - else: - color_temp = self.block.colorTemp - - color_temp = min(self._max_kelvin, max(self._min_kelvin, color_temp)) - return int(color_temperature_kelvin_to_mired(color_temp)) + return min( + self.max_color_temp_kelvin, + max(self.min_color_temp_kelvin, color_temp), + ) @property def effect_list(self) -> list[str] | None: """Return the list of supported effects.""" - if not self.supported_features & LightEntityFeature.EFFECT: - return None - - if self.wrapper.model == "SHBLB-1": + if self.coordinator.model == "SHBLB-1": return list(SHBLB_1_RGB_EFFECTS.values()) return list(STANDARD_RGB_EFFECTS.values()) @@ -276,15 +259,12 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): @property def effect(self) -> str | None: """Return the current effect.""" - if not self.supported_features & LightEntityFeature.EFFECT: - return None - if self.control_result: effect_index = self.control_result["effect"] else: effect_index = self.block.effect - if self.wrapper.model == "SHBLB-1": + if self.coordinator.model == "SHBLB-1": return SHBLB_1_RGB_EFFECTS[effect_index] return STANDARD_RGB_EFFECTS[effect_index] @@ -312,12 +292,19 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): if hasattr(self.block, "brightness"): params["brightness"] = brightness_pct - if ATTR_COLOR_TEMP in kwargs and ColorMode.COLOR_TEMP in supported_color_modes: - color_temp = color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) - color_temp = min(self._max_kelvin, max(self._min_kelvin, color_temp)) + if ( + ATTR_COLOR_TEMP_KELVIN in kwargs + and ColorMode.COLOR_TEMP in supported_color_modes + ): # Color temperature change - used only in white mode, switch device mode to white + color_temp = kwargs[ATTR_COLOR_TEMP_KELVIN] set_mode = "white" - params["temp"] = int(color_temp) + params["temp"] = int( + min( + self.max_color_temp_kelvin, + max(self.min_color_temp_kelvin, color_temp), + ) + ) if ATTR_RGB_COLOR in kwargs and ColorMode.RGB in supported_color_modes: # Color channels change - used only in color mode, switch device mode to color @@ -331,10 +318,10 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): ATTR_RGBW_COLOR ] - if ATTR_EFFECT in kwargs and ATTR_COLOR_TEMP not in kwargs: + if ATTR_EFFECT in kwargs and ATTR_COLOR_TEMP_KELVIN not in kwargs: # Color effect change - used only in color mode, switch device mode to color set_mode = "color" - if self.wrapper.model == "SHBLB-1": + if self.coordinator.model == "SHBLB-1": effect_dict = SHBLB_1_RGB_EFFECTS else: effect_dict = STANDARD_RGB_EFFECTS @@ -346,13 +333,13 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): LOGGER.error( "Effect '%s' not supported by device %s", kwargs[ATTR_EFFECT], - self.wrapper.model, + self.coordinator.model, ) if ( set_mode and set_mode != self.mode - and self.wrapper.model in DUAL_MODE_LIGHT_MODELS + and self.coordinator.model in DUAL_MODE_LIGHT_MODELS ): params["mode"] = set_mode @@ -385,15 +372,15 @@ class RpcShellyLight(ShellyRpcEntity, LightEntity): _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} - def __init__(self, wrapper: RpcDeviceWrapper, id_: int) -> None: + def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: """Initialize light.""" - super().__init__(wrapper, f"switch:{id_}") + super().__init__(coordinator, f"switch:{id_}") self._id = id_ @property def is_on(self) -> bool: """If light is on.""" - return bool(self.wrapper.device.status[self.key]["output"]) + return bool(self.coordinator.device.status[self.key]["output"]) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index 337b40fff04ea3efbf3a5e13d659478755b91c73..384651123452c6a09f73afb8f45c3aeecc16f87d 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -8,7 +8,6 @@ from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import EventType -from . import get_block_device_wrapper, get_rpc_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, @@ -18,6 +17,10 @@ from .const import ( EVENT_SHELLY_CLICK, RPC_INPUTS_EVENTS_TYPES, ) +from .coordinator import ( + get_block_coordinator_by_device_id, + get_rpc_coordinator_by_device_id, +) from .utils import get_block_device_name, get_rpc_entity_name @@ -37,15 +40,15 @@ def async_describe_events( input_name = f"{event.data[ATTR_DEVICE]} channel {channel}" if click_type in RPC_INPUTS_EVENTS_TYPES: - rpc_wrapper = get_rpc_device_wrapper(hass, device_id) - if rpc_wrapper and rpc_wrapper.device.initialized: + rpc_coordinator = get_rpc_coordinator_by_device_id(hass, device_id) + if rpc_coordinator and rpc_coordinator.device.initialized: key = f"input:{channel-1}" - input_name = get_rpc_entity_name(rpc_wrapper.device, key) + input_name = get_rpc_entity_name(rpc_coordinator.device, key) elif click_type in BLOCK_INPUTS_EVENTS_TYPES: - block_wrapper = get_block_device_wrapper(hass, device_id) - if block_wrapper and block_wrapper.device.initialized: - device_name = get_block_device_name(block_wrapper.device) + block_coordinator = get_block_coordinator_by_device_id(hass, device_id) + if block_coordinator and block_coordinator.device.initialized: + device_name = get_block_device_name(block_coordinator.device) input_name = f"{device_name} channel {channel}" return { diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index d9a0e21764a3e5459a46e46eee1c54abbdd9bfd0..70970e73e307aeff58ee5e3eca930846e5df55f6 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -3,7 +3,8 @@ "name": "Shelly", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/shelly", - "requirements": ["aioshelly==2.0.2"], + "requirements": ["aioshelly==4.1.2"], + "dependencies": ["http"], "zeroconf": [ { "type": "_http._tcp.local.", @@ -12,5 +13,6 @@ ], "codeowners": ["@balloob", "@bieniu", "@thecode", "@chemelli74"], "iot_class": "local_push", - "loggers": ["aioshelly"] + "loggers": ["aioshelly"], + "integration_type": "device" } diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 6658daf674f98967d99d6fda48f1b80e81afea85..bb7f17ea18d56fc142ca64134e515a0f46e8ac45 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -1,11 +1,10 @@ """Number for Shelly.""" from __future__ import annotations -import asyncio from dataclasses import dataclass from typing import Any, Final, cast -import async_timeout +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.number import ( NumberEntity, @@ -15,11 +14,12 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry -from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, CONF_SLEEP_PERIOD, LOGGER +from .const import CONF_SLEEP_PERIOD, LOGGER from .entity import ( BlockEntityDescription, ShellySleepingBlockAttributeEntity, @@ -115,15 +115,13 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, NumberEntity): async def _set_state_full_path(self, path: str, params: Any) -> Any: """Set block state (HTTP request).""" - LOGGER.debug("Setting state for entity %s, state: %s", self.name, params) try: - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - return await self.wrapper.device.http_request("get", path, params) - except (asyncio.TimeoutError, OSError) as err: - LOGGER.error( - "Setting state for entity %s failed, state: %s, error: %s", - self.name, - params, - repr(err), - ) + return await self.coordinator.device.http_request("get", path, params) + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + f"Setting state for entity {self.name} failed, state: {params}, error: {repr(err)}" + ) from err + except InvalidAuthError: + self.coordinator.entry.async_start_reauth(self.hass) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index fe7ca1a9f839f8e8acefc3a40909d04d80d21584..3ddabf7ca2b7acef2e344f4d1829ce92874cdbbd 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -32,8 +32,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType -from . import BlockDeviceWrapper from .const import CONF_SLEEP_PERIOD, SHAIR_MAX_WORK_HOURS +from .coordinator import ShellyBlockCoordinator from .entity import ( BlockEntityDescription, RestEntityDescription, @@ -42,6 +42,7 @@ from .entity import ( ShellyRestAttributeEntity, ShellyRpcAttributeEntity, ShellySleepingBlockAttributeEntity, + ShellySleepingRpcAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rest, async_setup_entry_rpc, @@ -348,17 +349,17 @@ RPC_SENSORS: Final = { "temperature": RpcSensorDescription( key="switch", sub_key="temperature", - name="Temperature", + name="Device Temperature", native_unit_of_measurement=TEMP_CELSIUS, value=lambda status, _: round(status["tC"], 1), device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, - use_polling_wrapper=True, + use_polling_coordinator=True, ), "temperature_0": RpcSensorDescription( - key="temperature:0", + key="temperature", sub_key="tC", name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, @@ -376,7 +377,7 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, - use_polling_wrapper=True, + use_polling_coordinator=True, ), "uptime": RpcSensorDescription( key="sys", @@ -386,10 +387,10 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.TIMESTAMP, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, - use_polling_wrapper=True, + use_polling_coordinator=True, ), "humidity_0": RpcSensorDescription( - key="humidity:0", + key="humidity", sub_key="rh", name="Humidity", native_unit_of_measurement=PERCENTAGE, @@ -410,6 +411,26 @@ RPC_SENSORS: Final = { entity_registry_enabled_default=True, entity_category=EntityCategory.DIAGNOSTIC, ), + "voltmeter": RpcSensorDescription( + key="voltmeter", + sub_key="voltage", + name="Voltmeter", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + value=lambda status, _: round(float(status), 2), + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=True, + available=lambda status: status is not None, + ), + "analoginput": RpcSensorDescription( + key="analoginput", + sub_key="percent", + name="Analog Input", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=True, + ), } @@ -431,9 +452,19 @@ async def async_setup_entry( ) -> None: """Set up sensors for device.""" if get_device_entry_gen(config_entry) == 2: - return async_setup_entry_rpc( - hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor - ) + if config_entry.data[CONF_SLEEP_PERIOD]: + async_setup_entry_rpc( + hass, + config_entry, + async_add_entities, + RPC_SENSORS, + RpcSleepingSensor, + ) + else: + async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor + ) + return if config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_attribute_entities( @@ -465,13 +496,13 @@ class BlockSensor(ShellyBlockAttributeEntity, SensorEntity): def __init__( self, - wrapper: BlockDeviceWrapper, + coordinator: ShellyBlockCoordinator, block: Block, attribute: str, description: BlockSensorDescription, ) -> None: """Initialize sensor.""" - super().__init__(wrapper, block, attribute, description) + super().__init__(coordinator, block, attribute, description) self._attr_native_unit_of_measurement = description.native_unit_of_measurement if unit_fn := description.unit_fn: @@ -512,7 +543,7 @@ class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): def __init__( self, - wrapper: BlockDeviceWrapper, + coordinator: ShellyBlockCoordinator, block: Block | None, attribute: str, description: BlockSensorDescription, @@ -520,7 +551,7 @@ class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): sensors: Mapping[tuple[str, str], BlockSensorDescription] | None = None, ) -> None: """Initialize the sleeping sensor.""" - super().__init__(wrapper, block, attribute, description, entry, sensors) + super().__init__(coordinator, block, attribute, description, entry, sensors) self._attr_native_unit_of_measurement = description.native_unit_of_measurement if block and (unit_fn := description.unit_fn): @@ -533,3 +564,17 @@ class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): return self.attribute_value return self.last_state + + +class RpcSleepingSensor(ShellySleepingRpcAttributeEntity, SensorEntity): + """Represent a RPC sleeping sensor.""" + + entity_description: RpcSensorDescription + + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + if self.coordinator.device.initialized: + return self.attribute_value + + return self.last_state diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index d65568d0a2a2196e2c5f7c143782abf7f939adad..39e754eaf86421308585b90085bf3946290f2b92 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -10,8 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BlockDeviceWrapper, RpcDeviceWrapper -from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC +from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ShellyBlockEntity, ShellyRpcEntity from .utils import ( async_remove_shelly_entity, @@ -41,31 +40,32 @@ def async_setup_block_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entities for block device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] + coordinator = get_entry_data(hass)[config_entry.entry_id].block + assert coordinator # In roller mode the relay blocks exist but do not contain required info if ( - wrapper.model in ["SHSW-21", "SHSW-25"] - and wrapper.device.settings["mode"] != "relay" + coordinator.model in ["SHSW-21", "SHSW-25"] + and coordinator.device.settings["mode"] != "relay" ): return relay_blocks = [] - assert wrapper.device.blocks - for block in wrapper.device.blocks: + assert coordinator.device.blocks + for block in coordinator.device.blocks: if block.type != "relay" or is_block_channel_type_light( - wrapper.device.settings, int(block.channel) + coordinator.device.settings, int(block.channel) ): continue relay_blocks.append(block) - unique_id = f"{wrapper.mac}-{block.type}_{block.channel}" + unique_id = f"{coordinator.mac}-{block.type}_{block.channel}" async_remove_shelly_entity(hass, "light", unique_id) if not relay_blocks: return - async_add_entities(BlockRelaySwitch(wrapper, block) for block in relay_blocks) + async_add_entities(BlockRelaySwitch(coordinator, block) for block in relay_blocks) @callback @@ -75,31 +75,31 @@ def async_setup_rpc_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entities for RPC device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC] - - switch_key_ids = get_rpc_key_ids(wrapper.device.status, "switch") + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + assert coordinator + switch_key_ids = get_rpc_key_ids(coordinator.device.status, "switch") switch_ids = [] for id_ in switch_key_ids: - if is_rpc_channel_type_light(wrapper.device.config, id_): + if is_rpc_channel_type_light(coordinator.device.config, id_): continue switch_ids.append(id_) - unique_id = f"{wrapper.mac}-switch:{id_}" + unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "light", unique_id) if not switch_ids: return - async_add_entities(RpcRelaySwitch(wrapper, id_) for id_ in switch_ids) + async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids) class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): """Entity that controls a relay on Block based Shelly devices.""" - def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: + def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: """Initialize relay switch.""" - super().__init__(wrapper, block) + super().__init__(coordinator, block) self.control_result: dict[str, Any] | None = None @property @@ -130,15 +130,15 @@ class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity): """Entity that controls a relay on RPC based Shelly devices.""" - def __init__(self, wrapper: RpcDeviceWrapper, id_: int) -> None: + def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: """Initialize relay switch.""" - super().__init__(wrapper, f"switch:{id_}") + super().__init__(coordinator, f"switch:{id_}") self._id = id_ @property def is_on(self) -> bool: """If switch is on.""" - return bool(self.wrapper.device.status[self.key]["output"]) + return bool(self.coordinator.device.status[self.key]["output"]) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on relay.""" diff --git a/homeassistant/components/shelly/translations/bg.json b/homeassistant/components/shelly/translations/bg.json index 131d4bf19c65d48757a124b5592b1d85626fef60..1cdcd4e5d865a3cb7ca4947deaae7669b5befac0 100644 --- a/homeassistant/components/shelly/translations/bg.json +++ b/homeassistant/components/shelly/translations/bg.json @@ -3,6 +3,7 @@ "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", "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", + "reauth_unsuccessful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u0442\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0431\u0435\u0448\u0435 \u043d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u0430, \u043c\u043e\u043b\u044f, \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0435\u0442\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0438 \u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e.", "unsupported_firmware": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 \u043d\u0435\u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430\u043d\u0430 \u0432\u0435\u0440\u0441\u0438\u044f \u043d\u0430 \u0444\u044a\u0440\u043c\u0443\u0435\u0440\u0430." }, "error": { diff --git a/homeassistant/components/shelly/translations/cs.json b/homeassistant/components/shelly/translations/cs.json index d7b817eb99425a94bb7ce0d7a8ffeba2eabcce3c..c2f45d0f4c79aae767ab6518d6f021baaca78a12 100644 --- a/homeassistant/components/shelly/translations/cs.json +++ b/homeassistant/components/shelly/translations/cs.json @@ -2,6 +2,8 @@ "config": { "abort": { "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9", + "reauth_unsuccessful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed se nezda\u0159ilo, odeberte pros\u00edm integraci a nastavte ji znovu.", "unsupported_firmware": "Za\u0159\u00edzen\u00ed pou\u017e\u00edv\u00e1 nepodporovanou verzi firmwaru." }, "error": { @@ -20,6 +22,12 @@ "username": "U\u017eivatelsk\u00e9 jm\u00e9no" } }, + "reauth_confirm": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + }, "user": { "data": { "host": "Hostitel" diff --git a/homeassistant/components/shelly/translations/el.json b/homeassistant/components/shelly/translations/el.json index a5680f9343a32ce34aa4e0af47faa5156903bf2d..01c7af19be02599501ddd17dde31e2e0cfc739ad 100644 --- a/homeassistant/components/shelly/translations/el.json +++ b/homeassistant/components/shelly/translations/el.json @@ -2,6 +2,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", + "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", + "reauth_unsuccessful": "\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 \u03b1\u03bd\u03b5\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2. \u039a\u03b1\u03c4\u03b1\u03c1\u03b3\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 \u03ba\u03b1\u03b9 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03be\u03b1\u03bd\u03ac.", "unsupported_firmware": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af \u03bc\u03b9\u03b1 \u03bc\u03b7 \u03c5\u03c0\u03bf\u03c3\u03c4\u03b7\u03c1\u03b9\u03b6\u03cc\u03bc\u03b5\u03bd\u03b7 \u03ad\u03ba\u03b4\u03bf\u03c3\u03b7 \u03c5\u03bb\u03b9\u03ba\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03bc\u03b9\u03ba\u03bf\u03cd." }, "error": { @@ -21,6 +23,12 @@ "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7" } }, + "reauth_confirm": { + "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" + } + }, "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/shelly/translations/et.json b/homeassistant/components/shelly/translations/et.json index e248ee9eba4623201513b7eba11c283f16cadd60..bc68caeb9bb7a2d41be4040ae12b04f63022acc0 100644 --- a/homeassistant/components/shelly/translations/et.json +++ b/homeassistant/components/shelly/translations/et.json @@ -2,6 +2,8 @@ "config": { "abort": { "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus", + "reauth_unsuccessful": "Taasautentimine eba\u00f5nnestus, eemaldage integratsioon ja seadistage see uuesti.", "unsupported_firmware": "Seade kasutab toetuseta p\u00fcsivara versiooni." }, "error": { @@ -21,6 +23,12 @@ "username": "Kasutajanimi" } }, + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + }, "user": { "data": { "host": "" diff --git a/homeassistant/components/shelly/translations/ja.json b/homeassistant/components/shelly/translations/ja.json index 748e70fd32aae0caf84ee591888bc4c6e11d073e..ac530669f7d2d8fe9a8a03e9913c6dd27912496e 100644 --- a/homeassistant/components/shelly/translations/ja.json +++ b/homeassistant/components/shelly/translations/ja.json @@ -2,6 +2,7 @@ "config": { "abort": { "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", "unsupported_firmware": "\u30b5\u30dd\u30fc\u30c8\u3055\u308c\u3066\u3044\u306a\u3044\u30d0\u30fc\u30b8\u30e7\u30f3\u306e\u30d5\u30a1\u30fc\u30e0\u30a6\u30a7\u30a2\u3092\u4f7f\u7528\u3057\u3066\u3044\u307e\u3059\u3002" }, "error": { @@ -21,6 +22,12 @@ "username": "\u30e6\u30fc\u30b6\u30fc\u540d" } }, + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + }, "user": { "data": { "host": "\u30db\u30b9\u30c8" diff --git a/homeassistant/components/shelly/translations/nb.json b/homeassistant/components/shelly/translations/nb.json index ef07be6f70df201f8e4d94bb75b006949f1049c5..c471008ba7caefd68180ae7eb64d8ad9f952ade7 100644 --- a/homeassistant/components/shelly/translations/nb.json +++ b/homeassistant/components/shelly/translations/nb.json @@ -1,11 +1,20 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "credentials": { "data": { "password": "Passord", "username": "Brukernavn" } + }, + "reauth_confirm": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } } } } diff --git a/homeassistant/components/shelly/translations/no.json b/homeassistant/components/shelly/translations/no.json index e6cd94ee09a1b08c54fdfac56acc7129911748b1..2f483843e521b7379a588ab08ad279b4e70f63a7 100644 --- a/homeassistant/components/shelly/translations/no.json +++ b/homeassistant/components/shelly/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "reauth_unsuccessful": "Re-autentisering mislyktes. Fjern integrasjonen og konfigurer den p\u00e5 nytt.", "unsupported_firmware": "Enheten bruker en ikke-st\u00f8ttet firmwareversjon." }, diff --git a/homeassistant/components/shelly/translations/pl.json b/homeassistant/components/shelly/translations/pl.json index c9c7496d13eccca8531ff90fb8c8bea25fe6023a..dd7d9d10486c1ced774720b0ef96b6064021b2d2 100644 --- a/homeassistant/components/shelly/translations/pl.json +++ b/homeassistant/components/shelly/translations/pl.json @@ -2,6 +2,8 @@ "config": { "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", + "reauth_unsuccessful": "B\u0142\u0105d ponownego uwierzytelnienia, usu\u0144 integracj\u0119 i skonfiguruj j\u0105 ponownie", "unsupported_firmware": "Urz\u0105dzenie u\u017cywa nieobs\u0142ugiwanej wersji firmware" }, "error": { @@ -21,6 +23,12 @@ "username": "Nazwa u\u017cytkownika" } }, + "reauth_confirm": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + }, "user": { "data": { "host": "Nazwa hosta lub adres IP" diff --git a/homeassistant/components/shelly/translations/pt-BR.json b/homeassistant/components/shelly/translations/pt-BR.json index 125334b8d281c5ccff47fe9bf060b7ce5fe0cf63..0a546c9807d5987299cb0c43ac0f6047a6c79145 100644 --- a/homeassistant/components/shelly/translations/pt-BR.json +++ b/homeassistant/components/shelly/translations/pt-BR.json @@ -2,6 +2,8 @@ "config": { "abort": { "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", + "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida", + "reauth_unsuccessful": "A reautentica\u00e7\u00e3o falhou. Remova a integra\u00e7\u00e3o e configure-a novamente.", "unsupported_firmware": "O dispositivo est\u00e1 usando uma vers\u00e3o de firmware n\u00e3o compat\u00edvel." }, "error": { @@ -21,6 +23,12 @@ "username": "Usu\u00e1rio" } }, + "reauth_confirm": { + "data": { + "password": "Senha", + "username": "Usu\u00e1rio" + } + }, "user": { "data": { "host": "Nome do host" diff --git a/homeassistant/components/shelly/translations/sv.json b/homeassistant/components/shelly/translations/sv.json index 62262c8558c2406016f964fd03adcafdcaaea68d..fb5a480f9e7f6a0d3d22bfabd61a4fa413da5f1e 100644 --- a/homeassistant/components/shelly/translations/sv.json +++ b/homeassistant/components/shelly/translations/sv.json @@ -2,6 +2,8 @@ "config": { "abort": { "already_configured": "Enheten \u00e4r redan konfigurerad", + "reauth_successful": "\u00c5terautentisering lyckades", + "reauth_unsuccessful": "\u00c5terautentiseringen misslyckades. Ta bort integrationen och konfigurera den igen.", "unsupported_firmware": "Enheten anv\u00e4nder en firmwareversion som inte st\u00f6ds." }, "error": { @@ -21,6 +23,12 @@ "username": "Anv\u00e4ndarnamn" } }, + "reauth_confirm": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + }, "user": { "data": { "host": "V\u00e4rd" diff --git a/homeassistant/components/shelly/translations/tr.json b/homeassistant/components/shelly/translations/tr.json index fac805e51347f294ba32abd04ec52025d6b068b6..435d5a3ec5697a825cc5cca204d9c3927a7ffb5e 100644 --- a/homeassistant/components/shelly/translations/tr.json +++ b/homeassistant/components/shelly/translations/tr.json @@ -2,6 +2,8 @@ "config": { "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", + "reauth_unsuccessful": "Yeniden kimlik do\u011frulama ba\u015far\u0131s\u0131z oldu, l\u00fctfen entegrasyonu kald\u0131r\u0131n ve yeniden kurun.", "unsupported_firmware": "Cihaz, desteklenmeyen bir versiyon s\u00fcr\u00fcm\u00fc kullan\u0131yor." }, "error": { @@ -21,6 +23,12 @@ "username": "Kullan\u0131c\u0131 Ad\u0131" } }, + "reauth_confirm": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + }, "user": { "data": { "host": "Sunucu" diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index ac4b737a2cce6ddf7769ae590b7e28ae6ca93bad..d801d0d03b3eff92f5833d61e2a8b2b9f6368b75 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -6,6 +6,8 @@ from dataclasses import dataclass import logging from typing import Any, Final, cast +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError + from homeassistant.components.update import ( UpdateDeviceClass, UpdateEntity, @@ -14,11 +16,13 @@ from homeassistant.components.update import ( ) 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 homeassistant.util import slugify -from . import BlockDeviceWrapper, RpcDeviceWrapper -from .const import BLOCK, CONF_SLEEP_PERIOD, DATA_CONFIG_ENTRY, DOMAIN +from .const import CONF_SLEEP_PERIOD +from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ( RestEntityDescription, RpcEntityDescription, @@ -27,7 +31,12 @@ from .entity import ( async_setup_entry_rest, async_setup_entry_rpc, ) -from .utils import get_device_entry_gen +from .utils import ( + async_remove_shelly_entity, + get_block_device_name, + get_device_entry_gen, + get_rpc_device_name, +) LOGGER = logging.getLogger(__name__) @@ -37,7 +46,7 @@ class RpcUpdateRequiredKeysMixin: """Class for RPC update required keys.""" latest_version: Callable[[dict], Any] - install: Callable + beta: bool @dataclass @@ -45,7 +54,7 @@ class RestUpdateRequiredKeysMixin: """Class for REST update required keys.""" latest_version: Callable[[dict], Any] - install: Callable + beta: bool @dataclass @@ -67,7 +76,7 @@ REST_UPDATES: Final = { name="Firmware Update", key="fwupdate", latest_version=lambda status: status["update"]["new_version"], - install=lambda wrapper: wrapper.async_trigger_ota_update(), + beta=False, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -76,7 +85,7 @@ REST_UPDATES: Final = { name="Beta Firmware Update", key="fwupdate", latest_version=lambda status: status["update"].get("beta_version"), - install=lambda wrapper: wrapper.async_trigger_ota_update(beta=True), + beta=True, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -88,10 +97,8 @@ RPC_UPDATES: Final = { name="Firmware Update", key="sys", sub_key="available_updates", - latest_version=lambda status: status.get("stable", {"version": None})[ - "version" - ], - install=lambda wrapper: wrapper.async_trigger_ota_update(), + latest_version=lambda status: status.get("stable", {"version": ""})["version"], + beta=False, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -100,8 +107,8 @@ RPC_UPDATES: Final = { name="Beta Firmware Update", key="sys", sub_key="available_updates", - latest_version=lambda status: status.get("beta", {"version": None})["version"], - install=lambda wrapper: wrapper.async_trigger_ota_update(beta=True), + latest_version=lambda status: status.get("beta", {"version": ""})["version"], + beta=True, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -116,10 +123,28 @@ async def async_setup_entry( ) -> None: """Set up update entities for Shelly component.""" if get_device_entry_gen(config_entry) == 2: + # Remove legacy update binary sensor & buttons, remove in 2023.2.0 + rpc_coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + assert rpc_coordinator + mac = rpc_coordinator.mac + async_remove_shelly_entity(hass, "binary_sensor", f"{mac}-sys-fwupdate") + device_name = slugify(get_rpc_device_name(rpc_coordinator.device)) + async_remove_shelly_entity(hass, "button", f"{device_name}_ota_update") + async_remove_shelly_entity(hass, "button", f"{device_name}_ota_update_beta") + return async_setup_entry_rpc( hass, config_entry, async_add_entities, RPC_UPDATES, RpcUpdateEntity ) + # Remove legacy update binary sensor & buttons, remove in 2023.2.0 + block_coordinator = get_entry_data(hass)[config_entry.entry_id].block + assert block_coordinator + mac = block_coordinator.mac + async_remove_shelly_entity(hass, "binary_sensor", f"{mac}-fwupdate") + device_name = slugify(get_block_device_name(block_coordinator.device)) + async_remove_shelly_entity(hass, "button", f"{device_name}_ota_update") + async_remove_shelly_entity(hass, "button", f"{device_name}_ota_update_beta") + if not config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_rest( hass, @@ -140,30 +165,26 @@ class RestUpdateEntity(ShellyRestAttributeEntity, UpdateEntity): def __init__( self, - wrapper: BlockDeviceWrapper, + block_coordinator: ShellyBlockCoordinator, attribute: str, description: RestEntityDescription, ) -> None: """Initialize update entity.""" - super().__init__(wrapper, attribute, description) + super().__init__(block_coordinator, attribute, description) self._in_progress_old_version: str | None = None @property def installed_version(self) -> str | None: """Version currently in use.""" - version = self.wrapper.device.status["update"]["old_version"] - if version is None: - return None - - return cast(str, version) + return cast(str, self.block_coordinator.device.status["update"]["old_version"]) @property def latest_version(self) -> str | None: """Latest version available for install.""" new_version = self.entity_description.latest_version( - self.wrapper.device.status, + self.block_coordinator.device.status, ) - if new_version not in (None, ""): + if new_version: return cast(str, new_version) return self.installed_version @@ -177,12 +198,29 @@ class RestUpdateEntity(ShellyRestAttributeEntity, UpdateEntity): self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install the latest firmware version.""" - config_entry = self.wrapper.entry - block_wrapper = self.hass.data[DOMAIN][DATA_CONFIG_ENTRY][ - config_entry.entry_id - ].get(BLOCK) self._in_progress_old_version = self.installed_version - await self.entity_description.install(block_wrapper) + beta = self.entity_description.beta + update_data = self.coordinator.device.status["update"] + LOGGER.debug("OTA update service - update_data: %s", update_data) + + new_version = update_data["new_version"] + if beta: + new_version = update_data["beta_version"] + + LOGGER.info( + "Starting OTA update of device %s from '%s' to '%s'", + self.name, + self.coordinator.device.firmware_version, + new_version, + ) + try: + result = await self.coordinator.device.trigger_ota_update(beta=beta) + except DeviceConnectionError as err: + raise HomeAssistantError(f"Error starting OTA update: {repr(err)}") from err + except InvalidAuthError: + self.coordinator.entry.async_start_reauth(self.hass) + else: + LOGGER.debug("Result of OTA update call: %s", result) class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): @@ -195,30 +233,28 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): def __init__( self, - wrapper: RpcDeviceWrapper, + coordinator: ShellyRpcCoordinator, key: str, attribute: str, description: RpcEntityDescription, ) -> None: """Initialize update entity.""" - super().__init__(wrapper, key, attribute, description) + super().__init__(coordinator, key, attribute, description) self._in_progress_old_version: str | None = None @property def installed_version(self) -> str | None: """Version currently in use.""" - if self.wrapper.device.shelly is None: - return None - - return cast(str, self.wrapper.device.shelly["ver"]) + assert self.coordinator.device.shelly + return cast(str, self.coordinator.device.shelly["ver"]) @property def latest_version(self) -> str | None: """Latest version available for install.""" new_version = self.entity_description.latest_version( - self.wrapper.device.status[self.key][self.entity_description.sub_key], + self.coordinator.device.status[self.key][self.entity_description.sub_key], ) - if new_version is not None: + if new_version: return cast(str, new_version) return self.installed_version @@ -233,4 +269,29 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): ) -> None: """Install the latest firmware version.""" self._in_progress_old_version = self.installed_version - await self.entity_description.install(self.wrapper) + beta = self.entity_description.beta + update_data = self.coordinator.device.status["sys"]["available_updates"] + LOGGER.debug("OTA update service - update_data: %s", update_data) + + new_version = update_data.get("stable", {"version": ""})["version"] + if beta: + new_version = update_data.get("beta", {"version": ""})["version"] + + LOGGER.info( + "Starting OTA update of device %s from '%s' to '%s'", + self.coordinator.name, + self.coordinator.device.firmware_version, + new_version, + ) + try: + await self.coordinator.device.trigger_ota_update(beta=beta) + except DeviceConnectionError as err: + raise HomeAssistantError( + f"OTA update connection error: {repr(err)}" + ) from err + except RpcCallError as err: + raise HomeAssistantError(f"OTA update request error: {repr(err)}") from err + except InvalidAuthError: + self.coordinator.entry.async_start_reauth(self.hass) + else: + LOGGER.debug("OTA update call successful") diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 7eeb93f291801dfe53ff1dd03e38d4652fe24bf4..c3b6d24752f591165ab3382fab035c35affa14e1 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -4,10 +4,12 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import Any, cast +from aiohttp.web import Request, WebSocketResponse from aioshelly.block_device import BLOCK_VALUE_UNIT, COAP, Block, BlockDevice from aioshelly.const import MODEL_NAMES -from aioshelly.rpc_device import RpcDevice +from aioshelly.rpc_device import RpcDevice, WsServer +from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, callback @@ -21,7 +23,6 @@ from .const import ( DEFAULT_COAP_PORT, DOMAIN, LOGGER, - MAX_RPC_KEY_INSTANCES, RPC_INPUTS_EVENTS_TYPES, SHBTN_INPUTS_EVENTS_TYPES, SHBTN_MODELS, @@ -213,7 +214,7 @@ def get_shbtn_input_triggers() -> list[tuple[str, str]]: @singleton.singleton("shelly_coap") async def get_coap_context(hass: HomeAssistant) -> COAP: - """Get CoAP context to be used in all Shelly devices.""" + """Get CoAP context to be used in all Shelly Gen1 devices.""" context = COAP() if DOMAIN in hass.data: port = hass.data[DOMAIN].get(CONF_COAP_PORT, DEFAULT_COAP_PORT) @@ -227,10 +228,33 @@ async def get_coap_context(hass: HomeAssistant) -> COAP: context.close() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener) - return context +class ShellyReceiver(HomeAssistantView): + """Handle pushes from Shelly Gen2 devices.""" + + requires_auth = False + url = "/api/shelly/ws" + name = "api:shelly:ws" + + def __init__(self, ws_server: WsServer) -> None: + """Initialize the Shelly receiver view.""" + self._ws_server = ws_server + + async def get(self, request: Request) -> WebSocketResponse: + """Start a get request.""" + return await self._ws_server.websocket_handler(request) + + +@singleton.singleton("shelly_ws_server") +async def get_ws_context(hass: HomeAssistant) -> WsServer: + """Get websocket server context to be used in all Shelly Gen2 devices.""" + ws_server = WsServer() + hass.http.register_view(ShellyReceiver(ws_server)) + return ws_server + + def get_block_device_sleep_period(settings: dict[str, Any]) -> int: """Return the device sleep period in seconds or 0 for non sleeping devices.""" sleep_period = 0 @@ -243,6 +267,16 @@ def get_block_device_sleep_period(settings: dict[str, Any]) -> int: return sleep_period * 60 # minutes to seconds +def get_rpc_device_sleep_period(config: dict[str, Any]) -> int: + """Return the device sleep period in seconds or 0 for non sleeping devices.""" + return cast(int, config["sys"].get("sleep", {}).get("wakeup_period", 0)) + + +def get_rpc_device_wakeup_period(status: dict[str, Any]) -> int: + """Return the device wakeup period in seconds or 0 for non sleeping devices.""" + return cast(int, status["sys"].get("wakeup_period", 0)) + + def get_info_auth(info: dict[str, Any]) -> bool: """Return true if device has authorization enabled.""" return cast(bool, info.get("auth") or info.get("auth_en")) @@ -303,28 +337,12 @@ def get_rpc_key_instances(keys_dict: dict[str, Any], key: str) -> list[str]: if key == "switch" and "cover:0" in keys_dict: key = "cover" - keys_list: list[str] = [] - for i in range(MAX_RPC_KEY_INSTANCES): - key_inst = f"{key}:{i}" - if key_inst not in keys_dict: - return keys_list - - keys_list.append(key_inst) - - return keys_list + return [k for k in keys_dict if k.startswith(key)] def get_rpc_key_ids(keys_dict: dict[str, Any], key: str) -> list[int]: """Return list of key ids for RPC device from a dict.""" - key_ids: list[int] = [] - for i in range(MAX_RPC_KEY_INSTANCES): - key_inst = f"{key}:{i}" - if key_inst not in keys_dict: - return key_ids - - key_ids.append(i) - - return key_ids + return [int(k.split(":")[1]) for k in keys_dict if k.startswith(key)] def is_rpc_momentary_input( diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 7344b7295392ba68cda61e9f6a5a3c4e6096beb6..0f7afe3240e0d99589308b68153a2b4863ee9680 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -1,6 +1,7 @@ """Support to manage a shopping list.""" from http import HTTPStatus import logging +from typing import Any import uuid import voluptuous as vol @@ -359,8 +360,8 @@ class ClearCompletedItemsView(http.HomeAssistantView): @callback def websocket_handle_items( hass: HomeAssistant, - connection: websocket_api.connection.ActiveConnection, - msg: dict, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], ) -> None: """Handle get shopping_list items.""" connection.send_message( @@ -371,8 +372,8 @@ def websocket_handle_items( @websocket_api.async_response async def websocket_handle_add( hass: HomeAssistant, - connection: websocket_api.connection.ActiveConnection, - msg: dict, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], ) -> None: """Handle add item to shopping_list.""" item = await hass.data[DOMAIN].async_add(msg["name"], connection.context(msg)) @@ -382,8 +383,8 @@ async def websocket_handle_add( @websocket_api.async_response async def websocket_handle_update( hass: HomeAssistant, - connection: websocket_api.connection.ActiveConnection, - msg: dict, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], ) -> None: """Handle update shopping_list item.""" msg_id = msg.pop("id") @@ -405,8 +406,8 @@ async def websocket_handle_update( @websocket_api.async_response async def websocket_handle_clear( hass: HomeAssistant, - connection: websocket_api.connection.ActiveConnection, - msg: dict, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], ) -> None: """Handle clearing shopping_list items.""" await hass.data[DOMAIN].async_clear_completed(connection.context(msg)) @@ -421,8 +422,8 @@ async def websocket_handle_clear( ) def websocket_handle_reorder( hass: HomeAssistant, - connection: websocket_api.connection.ActiveConnection, - msg: dict, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], ) -> None: """Handle reordering shopping_list items.""" msg_id = msg.pop("id") diff --git a/homeassistant/components/shopping_list/translations/no.json b/homeassistant/components/shopping_list/translations/no.json index 6bad2dd5774d189b5b9f1d172d726f8cb240636a..c6754d8a6aa33686d5cdd03c35d6f75329eca57d 100644 --- a/homeassistant/components/shopping_list/translations/no.json +++ b/homeassistant/components/shopping_list/translations/no.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "\u00d8nsker du \u00e5 konfigurere handleliste?", + "description": "\u00d8nsker du \u00e5 konfigurere handlelisten?", "title": "Handleliste" } } diff --git a/homeassistant/components/sia/translations/nb.json b/homeassistant/components/sia/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/sia/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/config_flow.py b/homeassistant/components/simplepush/config_flow.py index 0f0073c5099c360272270af26aa4004c8f884d56..01ca508a5c42d74c051ee654dac5c1c210943da2 100644 --- a/homeassistant/components/simplepush/config_flow.py +++ b/homeassistant/components/simplepush/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from simplepush import UnknownError, send, send_encrypted +from simplepush import UnknownError, send import voluptuous as vol from homeassistant import config_entries @@ -17,15 +17,19 @@ 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", + send( + key=entry[CONF_DEVICE_KEY], + password=entry[CONF_PASSWORD], + salt=entry[CONF_PASSWORD], + title="HA test", + message="Message delivered successfully", ) else: - send(entry[CONF_DEVICE_KEY], "HA test", "Message delivered successfully") + send( + key=entry[CONF_DEVICE_KEY], + title="HA test", + message="Message delivered successfully", + ) except UnknownError: return {"base": "cannot_connect"} diff --git a/homeassistant/components/simplepush/manifest.json b/homeassistant/components/simplepush/manifest.json index 7c37546485a4b9a7a76af258bc40fc4dd563fe2b..5b2ad37d92aff98b291a0a84f4c64fcc012b528e 100644 --- a/homeassistant/components/simplepush/manifest.json +++ b/homeassistant/components/simplepush/manifest.json @@ -2,7 +2,7 @@ "domain": "simplepush", "name": "Simplepush", "documentation": "https://www.home-assistant.io/integrations/simplepush", - "requirements": ["simplepush==1.1.4"], + "requirements": ["simplepush==2.1.1"], "codeowners": ["@engrbm87"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index 108aaf3cbf6068cd42fec3062035d712f69221ec..b1c2eb5680ee8b54939c5b4da36552ca0337bc1c 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from simplepush import BadRequest, UnknownError, send, send_encrypted +from simplepush import BadRequest, UnknownError, send from homeassistant.components.notify import ( ATTR_DATA, @@ -71,16 +71,16 @@ class SimplePushNotificationService(BaseNotificationService): try: if self._password: - send_encrypted( - self._device_key, - self._password, - self._salt, - title, - message, + send( + key=self._device_key, + password=self._password, + salt=self._salt, + title=title, + message=message, event=event, ) else: - send(self._device_key, title, message, event=event) + send(key=self._device_key, title=title, message=message, event=event) except BadRequest: _LOGGER.error("Bad request. Title or message are too long") diff --git a/homeassistant/components/simplepush/translations/bg.json b/homeassistant/components/simplepush/translations/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..3e581f0623d604b7faaf2c9db370909e15841012 --- /dev/null +++ b/homeassistant/components/simplepush/translations/bg.json @@ -0,0 +1,17 @@ +{ + "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" + }, + "step": { + "user": { + "data": { + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/id.json b/homeassistant/components/simplepush/translations/id.json index de54996d014b85a8cfdf91bd32797c798198df09..954e025cf8989c571bc4dc60f498e7f20b9058b8 100644 --- a/homeassistant/components/simplepush/translations/id.json +++ b/homeassistant/components/simplepush/translations/id.json @@ -24,8 +24,8 @@ "title": "Konfigurasi YAML Simplepush dalam proses penghapusan" }, "removed_yaml": { - "description": "Proses konfigurasi Simplepush lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML Simplepush dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Simplepush telah dihapus" + "description": "Proses konfigurasi Integrasi Simplepush lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML Simplepush dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Simplepush telah dihapus" } } } \ No newline at end of file diff --git a/homeassistant/components/simplisafe/diagnostics.py b/homeassistant/components/simplisafe/diagnostics.py index cd6e4ca52bec7b502248111a490c6ea73da566ec..cb983f7420228dbcd8b682a61fd1f54e52f82c2a 100644 --- a/homeassistant/components/simplisafe/diagnostics.py +++ b/homeassistant/components/simplisafe/diagnostics.py @@ -5,7 +5,14 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_CODE, CONF_LOCATION +from homeassistant.const import ( + CONF_ADDRESS, + CONF_CODE, + CONF_LOCATION, + CONF_TOKEN, + CONF_UNIQUE_ID, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from . import SimpliSafe @@ -18,6 +25,7 @@ CONF_PAYMENT_PROFILE_ID = "paymentProfileId" CONF_SERIAL = "serial" CONF_SID = "sid" CONF_SYSTEM_ID = "system_id" +CONF_TITLE = "title" CONF_UID = "uid" CONF_WIFI_SSID = "wifi_ssid" @@ -32,7 +40,13 @@ TO_REDACT = { CONF_SERIAL, CONF_SID, CONF_SYSTEM_ID, + # Config entry title may contain sensitive data: + CONF_TITLE, + CONF_TOKEN, CONF_UID, + # Config entry unique ID may contain sensitive data: + CONF_UNIQUE_ID, + CONF_USERNAME, CONF_WIFI_SSID, } @@ -45,9 +59,7 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { - "entry": { - "options": dict(entry.options), - }, + "entry": entry.as_dict(), "subscription_data": simplisafe.subscription_data, "systems": [system.as_dict() for system in simplisafe.systems.values()], }, diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index b08799e408235d32a02fa8b8d15b925a9e3e20fa..dcb06aa2825da9c370cbad217c107edbd6391589 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -12,5 +12,6 @@ "macaddress": "30AEA4*" } ], - "loggers": ["simplipy"] + "loggers": ["simplipy"], + "integration_type": "hub" } diff --git a/homeassistant/components/simplisafe/translations/nb.json b/homeassistant/components/simplisafe/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/simplisafe/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/translations/nl.json b/homeassistant/components/simplisafe/translations/nl.json index 8c356ed9e12211534377e19c287b8b79a682aa94..b24bde70faca87680112efaeb82839bcb3d4ef81 100644 --- a/homeassistant/components/simplisafe/translations/nl.json +++ b/homeassistant/components/simplisafe/translations/nl.json @@ -35,6 +35,11 @@ } } }, + "issues": { + "deprecated_service": { + "title": "De {deprecated_service}-service wordt verwijderd" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/simplisafe/translations/no.json b/homeassistant/components/simplisafe/translations/no.json index e2c369e9dd85554c414bf25e667fd7a101e24c14..5d6f81c444423566a61bd6700a5621b24fbcd0b1 100644 --- a/homeassistant/components/simplisafe/translations/no.json +++ b/homeassistant/components/simplisafe/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Denne SimpliSafe-kontoen er allerede i bruk.", "email_2fa_timed_out": "Tidsavbrudd mens du ventet p\u00e5 e-postbasert tofaktorautentisering.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "wrong_account": "Oppgitt brukerlegitimasjon samsvarer ikke med denne SimpliSafe-kontoen." }, "error": { diff --git a/homeassistant/components/simplisafe/translations/sv.json b/homeassistant/components/simplisafe/translations/sv.json index 61e004329503945c4f74ca78f070aa391349b88c..a2d75f36697cd7c05b44f032f770695cb60c87aa 100644 --- a/homeassistant/components/simplisafe/translations/sv.json +++ b/homeassistant/components/simplisafe/translations/sv.json @@ -39,6 +39,12 @@ } } }, + "issues": { + "deprecated_service": { + "description": "Uppdatera alla automatiseringar eller skript som anv\u00e4nder den h\u00e4r tj\u00e4nsten f\u00f6r att ist\u00e4llet anv\u00e4nda tj\u00e4nsten ` {alternate_service} ` med ett m\u00e5lenhets-ID p\u00e5 ` {alternate_target} `. Klicka sedan p\u00e5 SKICKA nedan f\u00f6r att markera problemet som l\u00f6st.", + "title": "Tj\u00e4nsten {deprecated_service} tas bort" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/simplisafe/translations/tr.json b/homeassistant/components/simplisafe/translations/tr.json index 02153b0b90cdb06c43b07e3cc93595ecbf2ce51c..f7073bb3df73eead0a3a9006dce5aec8a05c03e8 100644 --- a/homeassistant/components/simplisafe/translations/tr.json +++ b/homeassistant/components/simplisafe/translations/tr.json @@ -39,6 +39,12 @@ } } }, + "issues": { + "deprecated_service": { + "description": "Bu hizmeti kullanan t\u00fcm otomasyonlar\u0131 veya komut dosyalar\u0131n\u0131, bunun yerine \" {alternate_service} {alternate_target} hizmetini kullanacak \u015fekilde g\u00fcncelleyin. Ard\u0131ndan, bu sorunu \u00e7\u00f6z\u00fcld\u00fc olarak i\u015faretlemek i\u00e7in a\u015fa\u011f\u0131daki G\u00d6NDER'i t\u0131klay\u0131n.", + "title": "{deprecated_service} hizmeti kald\u0131r\u0131l\u0131yor" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/simply_automated/manifest.json b/homeassistant/components/simply_automated/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..3fce8ae27c3465599959112de76c9c9d1da386be --- /dev/null +++ b/homeassistant/components/simply_automated/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "simply_automated", + "name": "Simply Automated", + "integration_type": "virtual", + "supported_by": "upb" +} diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index 7acc30d0bd0d1aae638ee88a9e40d7e98533cf53..ff3ba47ae8531012846f25149dd175dab2f5c322 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from datetime import datetime from aioskybell import SkybellDevice from aioskybell.helpers import const as CONST @@ -17,15 +17,23 @@ 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 homeassistant.helpers.typing import StateType from .entity import DOMAIN, SkybellEntity @dataclass -class SkybellSensorEntityDescription(SensorEntityDescription): - """Class to describe a Skybell sensor.""" +class SkybellSensorEntityDescriptionMixIn: + """Mixin for Skybell sensor.""" + + value_fn: Callable[[SkybellDevice], StateType | datetime] + - value_fn: Callable[[SkybellDevice], Any] = lambda val: val +@dataclass +class SkybellSensorEntityDescription( + SensorEntityDescription, SkybellSensorEntityDescriptionMixIn +): + """Class to describe a Skybell sensor.""" SENSOR_TYPES: tuple[SkybellSensorEntityDescription, ...] = ( @@ -110,6 +118,6 @@ class SkybellSensor(SkybellEntity, SensorEntity): entity_description: SkybellSensorEntityDescription @property - def native_value(self) -> int: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/skybell/translations/bg.json b/homeassistant/components/skybell/translations/bg.json index a8057a452ab2d73af4c7c043610ea92d113aef71..556fba19234312573a9e71d572b95c8e3d63f0eb 100644 --- a/homeassistant/components/skybell/translations/bg.json +++ b/homeassistant/components/skybell/translations/bg.json @@ -19,7 +19,7 @@ }, "user": { "data": { - "email": "Email", + "email": "\u0418\u043c\u0435\u0439\u043b", "password": "\u041f\u0430\u0440\u043e\u043b\u0430" } } diff --git a/homeassistant/components/skybell/translations/he.json b/homeassistant/components/skybell/translations/he.json index 21b9822e24896870e99359c998c7699578fe4a31..0e3ced77bc3477e3835e986f29eb91bb52ab00c9 100644 --- a/homeassistant/components/skybell/translations/he.json +++ b/homeassistant/components/skybell/translations/he.json @@ -10,6 +10,11 @@ "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + }, "user": { "data": { "email": "\u05d3\u05d5\u05d0\"\u05dc", diff --git a/homeassistant/components/skybell/translations/id.json b/homeassistant/components/skybell/translations/id.json index 765c9cac274610f17d9e53f7c7670530efccba80..ebf4ea8be2482ba1588313d1c6f35fc958f4407c 100644 --- a/homeassistant/components/skybell/translations/id.json +++ b/homeassistant/components/skybell/translations/id.json @@ -27,8 +27,8 @@ }, "issues": { "removed_yaml": { - "description": "Proses konfigurasi Skybell lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Skybell telah dihapus" + "description": "Proses konfigurasi Integrasi Skybell lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Skybell telah dihapus" } } } \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/nb.json b/homeassistant/components/skybell/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/skybell/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/no.json b/homeassistant/components/skybell/translations/no.json index 25735dcf804c20c89c4016e33bce7a33ee874841..c152b2ae18e7278186d0b4f8d3fcb5afe4d9e5ae 100644 --- a/homeassistant/components/skybell/translations/no.json +++ b/homeassistant/components/skybell/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 57c1690e647b4d0eb416a293f43a2a2e7c190e8b..ab7be4ce514854b76a393e51f108709e106bb3b0 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -6,5 +6,6 @@ "requirements": ["slackclient==2.5.0"], "codeowners": ["@bachya", "@tkdrob"], "iot_class": "cloud_push", - "loggers": ["slack"] + "loggers": ["slack"], + "integration_type": "service" } diff --git a/homeassistant/components/slack/translations/nb.json b/homeassistant/components/slack/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/slack/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sleepiq/translations/no.json b/homeassistant/components/sleepiq/translations/no.json index ffe74f7048aa0351a5f5033a0d59b0f5329832a7..52a8ef891652afa3c7265f57251a3d3ab36c9065 100644 --- a/homeassistant/components/sleepiq/translations/no.json +++ b/homeassistant/components/sleepiq/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 8183e7a97c024296a6ed189d08837f51269273e7..83bf4258a95df9839394355c081e82d66bf45f7d 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -3,7 +3,7 @@ "name": "SMA Solar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sma", - "requirements": ["pysma==0.6.12"], + "requirements": ["pysma==0.7.2"], "codeowners": ["@kellerza", "@rklomp"], "iot_class": "local_polling", "loggers": ["pysma"] diff --git a/homeassistant/components/sma/translations/nb.json b/homeassistant/components/sma/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/sma/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smart_blinds/manifest.json b/homeassistant/components/smart_blinds/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..d0ddb30c5eea058b4b69361171049ebb8df9a229 --- /dev/null +++ b/homeassistant/components/smart_blinds/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "smart_blinds", + "name": "Smart Blinds", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/smart_home/manifest.json b/homeassistant/components/smart_home/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..7e420fb5404210ad2a503d96c36754e02ed2e73c --- /dev/null +++ b/homeassistant/components/smart_home/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "smart_home", + "name": "Smart Home", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/smart_meter_texas/translations/nb.json b/homeassistant/components/smart_meter_texas/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/smart_meter_texas/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarther/manifest.json b/homeassistant/components/smarther/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..6e87dd866fe0f403fd75863b564ce5e75106d24e --- /dev/null +++ b/homeassistant/components/smarther/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "smarther", + "name": "Smarther", + "integration_type": "virtual", + "supported_by": "netatmo" +} diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 648693472288c7b3ed840cdea4cd0af6ab50bb81..a51ee47f0dc144ff959f75b47e6e128a617cf224 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -98,7 +98,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { Attribute.body_weight_measurement, "Body Weight", MASS_KILOGRAMS, - None, + SensorDeviceClass.WEIGHT, SensorStateClass.MEASUREMENT, None, ) @@ -209,7 +209,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { Attribute.equivalent_carbon_dioxide_measurement, "Equivalent Carbon Dioxide Measurement", CONCENTRATION_PARTS_PER_MILLION, - None, + SensorDeviceClass.CO2, SensorStateClass.MEASUREMENT, None, ) @@ -229,7 +229,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { Attribute.gas_meter, "Gas Meter", ENERGY_KILO_WATT_HOUR, - None, + SensorDeviceClass.ENERGY, SensorStateClass.MEASUREMENT, None, ), @@ -248,7 +248,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { Attribute.gas_meter_volume, "Gas Meter Volume", VOLUME_CUBIC_METERS, - None, + SensorDeviceClass.VOLUME, SensorStateClass.MEASUREMENT, None, ), diff --git a/homeassistant/components/smarttub/translations/bg.json b/homeassistant/components/smarttub/translations/bg.json index ebfcda2158dfd1e6989951d8b91112321ac5d2cb..85d58d202d300d8cb20f1a26fb1a32dbb586f43c 100644 --- a/homeassistant/components/smarttub/translations/bg.json +++ b/homeassistant/components/smarttub/translations/bg.json @@ -4,8 +4,12 @@ "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": { + "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" + }, "user": { "data": { + "email": "\u0418\u043c\u0435\u0439\u043b", "password": "\u041f\u0430\u0440\u043e\u043b\u0430" } } diff --git a/homeassistant/components/smarttub/translations/no.json b/homeassistant/components/smarttub/translations/no.json index 10b3ccc421dc7ae1233054f9949c93bc6438a9a4..2689787f725b74dd6102d67512ea77231e80eb01 100644 --- a/homeassistant/components/smarttub/translations/no.json +++ b/homeassistant/components/smarttub/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning" diff --git a/homeassistant/components/sms/translations/nb.json b/homeassistant/components/sms/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/sms/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index 675a60e40969ef5dbaef15059fc26bd312711434..a88c91adff0de0b60e59ea14487465492f153a25 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -2,7 +2,7 @@ "domain": "snapcast", "name": "Snapcast", "documentation": "https://www.home-assistant.io/integrations/snapcast", - "requirements": ["snapcast==2.1.3"], + "requirements": ["snapcast==2.3.0"], "codeowners": [], "iot_class": "local_polling", "loggers": ["construct", "snapcast"] diff --git a/homeassistant/components/snooz/__init__.py b/homeassistant/components/snooz/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8349f781cf8855bb0cdf035377d0c59d39dd403e --- /dev/null +++ b/homeassistant/components/snooz/__init__.py @@ -0,0 +1,62 @@ +"""The Snooz component.""" +from __future__ import annotations + +import logging + +from pysnooz.device import SnoozDevice + +from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN, PLATFORMS +from .models import SnoozConfigurationData + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Snooz device from a config entry.""" + address: str = entry.data[CONF_ADDRESS] + token: str = entry.data[CONF_TOKEN] + + # transitions info logs are verbose. Only enable warnings + logging.getLogger("transitions.core").setLevel(logging.WARNING) + + if not (ble_device := async_ble_device_from_address(hass, address)): + raise ConfigEntryNotReady( + f"Could not find Snooz with address {address}. Try power cycling the device" + ) + + device = SnoozDevice(ble_device, token) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SnoozConfigurationData( + ble_device, device, entry.title + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id] + if entry.title != data.title: + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id] + + # also called by fan entities, but do it here too for good measure + await data.device.async_disconnect() + + hass.data[DOMAIN].pop(entry.entry_id) + + if not hass.config_entries.async_entries(DOMAIN): + hass.data.pop(DOMAIN) + + return unload_ok diff --git a/homeassistant/components/snooz/config_flow.py b/homeassistant/components/snooz/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..48f9370e403d2140e07782530967f63195357992 --- /dev/null +++ b/homeassistant/components/snooz/config_flow.py @@ -0,0 +1,206 @@ +"""Config flow for Snooz component.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any + +from pysnooz.advertisement import SnoozAdvertisementData +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfo, + async_discovered_service_info, + async_process_advertisements, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +# number of seconds to wait for a device to be put in pairing mode +WAIT_FOR_PAIRING_TIMEOUT = 30 + + +@dataclass +class DiscoveredSnooz: + """Represents a discovered Snooz device.""" + + info: BluetoothServiceInfo + device: SnoozAdvertisementData + + +class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Snooz.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery: DiscoveredSnooz | None = None + self._discovered_devices: dict[str, DiscoveredSnooz] = {} + self._pairing_task: asyncio.Task | None = None + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfo + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + device = SnoozAdvertisementData() + if not device.supported(discovery_info): + return self.async_abort(reason="not_supported") + self._discovery = DiscoveredSnooz(discovery_info, device) + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + assert self._discovery is not None + + if user_input is not None: + if not self._discovery.device.is_pairing: + return await self.async_step_wait_for_pairing_mode() + + return self._create_snooz_entry(self._discovery) + + self._set_confirm_only() + assert self._discovery.device.display_name + placeholders = {"name": self._discovery.device.display_name} + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="bluetooth_confirm", description_placeholders=placeholders + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + name = user_input[CONF_NAME] + + discovered = self._discovered_devices.get(name) + + assert discovered is not None + + self._discovery = discovered + + if not discovered.device.is_pairing: + return await self.async_step_wait_for_pairing_mode() + + address = discovered.info.address + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + return self._create_snooz_entry(discovered) + + configured_addresses = self._async_current_ids() + + for info in async_discovered_service_info(self.hass): + address = info.address + if address in configured_addresses: + continue + device = SnoozAdvertisementData() + if device.supported(info): + assert device.display_name + self._discovered_devices[device.display_name] = DiscoveredSnooz( + info, device + ) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_NAME): vol.In( + [ + d.device.display_name + for d in self._discovered_devices.values() + ] + ) + } + ), + ) + + async def async_step_wait_for_pairing_mode( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Wait for device to enter pairing mode.""" + if not self._pairing_task: + self._pairing_task = self.hass.async_create_task( + self._async_wait_for_pairing_mode() + ) + return self.async_show_progress( + step_id="wait_for_pairing_mode", + progress_action="wait_for_pairing_mode", + ) + + try: + await self._pairing_task + except asyncio.TimeoutError: + self._pairing_task = None + return self.async_show_progress_done(next_step_id="pairing_timeout") + + self._pairing_task = None + + return self.async_show_progress_done(next_step_id="pairing_complete") + + async def async_step_pairing_complete( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Create a configuration entry for a device that entered pairing mode.""" + assert self._discovery + + await self.async_set_unique_id( + self._discovery.info.address, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + return self._create_snooz_entry(self._discovery) + + async def async_step_pairing_timeout( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Inform the user that the device never entered pairing mode.""" + if user_input is not None: + return await self.async_step_wait_for_pairing_mode() + + self._set_confirm_only() + return self.async_show_form(step_id="pairing_timeout") + + def _create_snooz_entry(self, discovery: DiscoveredSnooz) -> FlowResult: + assert discovery.device.display_name + return self.async_create_entry( + title=discovery.device.display_name, + data={ + CONF_ADDRESS: discovery.info.address, + CONF_TOKEN: discovery.device.pairing_token, + }, + ) + + async def _async_wait_for_pairing_mode(self) -> None: + """Process advertisements until pairing mode is detected.""" + assert self._discovery + device = self._discovery.device + + def is_device_in_pairing_mode( + service_info: BluetoothServiceInfo, + ) -> bool: + return device.supported(service_info) and device.is_pairing + + try: + await async_process_advertisements( + self.hass, + is_device_in_pairing_mode, + {"address": self._discovery.info.address}, + BluetoothScanningMode.ACTIVE, + WAIT_FOR_PAIRING_TIMEOUT, + ) + finally: + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) diff --git a/homeassistant/components/snooz/const.py b/homeassistant/components/snooz/const.py new file mode 100644 index 0000000000000000000000000000000000000000..9ce16b80e05b4b58fe17698ee27c35fa4494a273 --- /dev/null +++ b/homeassistant/components/snooz/const.py @@ -0,0 +1,6 @@ +"""Constants for the Snooz component.""" + +from homeassistant.const import Platform + +DOMAIN = "snooz" +PLATFORMS: list[Platform] = [Platform.FAN] diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py new file mode 100644 index 0000000000000000000000000000000000000000..8ad2372924b1833a57fe3673a2f8f4811a62723d --- /dev/null +++ b/homeassistant/components/snooz/fan.py @@ -0,0 +1,119 @@ +"""Fan representation of a Snooz device.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from pysnooz.api import UnknownSnoozState +from pysnooz.commands import ( + SnoozCommandData, + SnoozCommandResultStatus, + set_volume, + turn_off, + turn_on, +) + +from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import DOMAIN +from .models import SnoozConfigurationData + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Snooz device from a config entry.""" + + data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id] + + async_add_entities([SnoozFan(data)]) + + +class SnoozFan(FanEntity, RestoreEntity): + """Fan representation of a Snooz device.""" + + def __init__(self, data: SnoozConfigurationData) -> None: + """Initialize a Snooz fan entity.""" + self._device = data.device + self._attr_name = data.title + self._attr_unique_id = data.device.address + self._attr_supported_features = FanEntityFeature.SET_SPEED + self._attr_should_poll = False + self._is_on: bool | None = None + self._percentage: int | None = None + + @callback + def _async_write_state_changed(self) -> None: + # cache state for restore entity + if not self.assumed_state: + self._is_on = self._device.state.on + self._percentage = self._device.state.volume + + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Restore state and subscribe to device events.""" + await super().async_added_to_hass() + + if last_state := await self.async_get_last_state(): + if last_state.state in (STATE_ON, STATE_OFF): + self._is_on = last_state.state == STATE_ON + else: + self._is_on = None + self._percentage = last_state.attributes.get(ATTR_PERCENTAGE) + + self.async_on_remove(self._async_subscribe_to_device_change()) + + @callback + def _async_subscribe_to_device_change(self) -> Callable[[], None]: + return self._device.subscribe_to_state_change(self._async_write_state_changed) + + @property + def percentage(self) -> int | None: + """Volume level of the device.""" + return self._percentage if self.assumed_state else self._device.state.volume + + @property + def is_on(self) -> bool | None: + """Power state of the device.""" + return self._is_on if self.assumed_state else self._device.state.on + + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return not self._device.is_connected or self._device.state is UnknownSnoozState + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the device.""" + await self._async_execute_command(turn_on(percentage)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + await self._async_execute_command(turn_off()) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the volume of the device. A value of 0 will turn off the device.""" + await self._async_execute_command( + set_volume(percentage) if percentage > 0 else turn_off() + ) + + async def _async_execute_command(self, command: SnoozCommandData) -> None: + result = await self._device.async_execute_command(command) + + if result.status == SnoozCommandResultStatus.SUCCESSFUL: + self._async_write_state_changed() + elif result.status != SnoozCommandResultStatus.CANCELLED: + raise HomeAssistantError( + f"Command {command} failed with status {result.status.name} after {result.duration}" + ) diff --git a/homeassistant/components/snooz/manifest.json b/homeassistant/components/snooz/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..1384767e8b8a351dd9f080639294a2e3f1bb68aa --- /dev/null +++ b/homeassistant/components/snooz/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "snooz", + "name": "Snooz", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/snooz", + "requirements": ["pysnooz==0.8.2"], + "dependencies": ["bluetooth"], + "codeowners": ["@AustinBrunkhorst"], + "bluetooth": [ + { + "local_name": "Snooz*" + }, + { + "service_uuid": "729f0608-496a-47fe-a124-3a62aaa3fbc0" + } + ], + "iot_class": "local_push" +} diff --git a/homeassistant/components/snooz/models.py b/homeassistant/components/snooz/models.py new file mode 100644 index 0000000000000000000000000000000000000000..d1c49fe9dc61a06a9a297ed00febda23ce36a813 --- /dev/null +++ b/homeassistant/components/snooz/models.py @@ -0,0 +1,15 @@ +"""Data models for the Snooz component.""" + +from dataclasses import dataclass + +from bleak.backends.device import BLEDevice +from pysnooz.device import SnoozDevice + + +@dataclass +class SnoozConfigurationData: + """Configuration data for Snooz.""" + + ble_device: BLEDevice + device: SnoozDevice + title: str diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..2f957f87072a16d8b23843c9a877f5c246930b8f --- /dev/null +++ b/homeassistant/components/snooz/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:component::bluetooth::config::step::user::data::address%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "pairing_timeout": { + "description": "The device did not enter pairing mode. Click Submit to try again.\n\n### Troubleshooting\n1. Check that the device isn't connected to the mobile app.\n2. Unplug the device for 5 seconds, then plug it back in." + } + }, + "progress": { + "wait_for_pairing_mode": "To complete setup, put this device in pairing mode.\n\n### How to enter pairing mode\n1. Force quit SNOOZ mobile apps.\n2. Press and hold the power button on the device. Release when the lights start blinking (approximately 5 seconds)." + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/snooz/translations/bg.json b/homeassistant/components/snooz/translations/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..a61dac839add5446a3bdc93746e136b88e3839fe --- /dev/null +++ b/homeassistant/components/snooz/translations/bg.json @@ -0,0 +1,20 @@ +{ + "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", + "no_devices_found": "\u041d\u044f\u043c\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 {name}?" + }, + "user": { + "data": { + "address": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0437\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/ca.json b/homeassistant/components/snooz/translations/ca.json new file mode 100644 index 0000000000000000000000000000000000000000..dca36286b901ad6434cbffe53bceac526a252967 --- /dev/null +++ b/homeassistant/components/snooz/translations/ca.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "no_devices_found": "No s'han trobat dispositius a la xarxa" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "Per completar la configuraci\u00f3, posa el dispositiu en mode de vinculaci\u00f3. \n\n### Com entrar en el mode de vinculaci\u00f3\n1. For\u00e7a el tancament de les aplicacions m\u00f2bils SNOOZ.\n2. Mant\u00e9 premut el bot\u00f3 d'engegada del dispositiu i allibera'l quan els llums comencin a parpellejar (despr\u00e9s d'uns 5 segons)." + }, + "step": { + "bluetooth_confirm": { + "description": "Vols configurar {name}?" + }, + "pairing_timeout": { + "description": "El dispositiu no ha entrat en mode de vinculaci\u00f3. Fes clic a Envia per tornar-ho a intentar.\n\n### Resoluci\u00f3 de problemes\n1. Comprova que el dispositiu no estigui connectat a l'aplicaci\u00f3 m\u00f2bil.\n2. Desconnecta el dispositiu durant 5 segons i, a continuaci\u00f3, torna'l a connectar." + }, + "user": { + "data": { + "address": "Dispositiu" + }, + "description": "Tria un dispositiu a configurar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/de.json b/homeassistant/components/snooz/translations/de.json new file mode 100644 index 0000000000000000000000000000000000000000..75b8acddc86ed48565348553c64c2866836d3df8 --- /dev/null +++ b/homeassistant/components/snooz/translations/de.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "Um die Einrichtung abzuschlie\u00dfen, versetze das Ger\u00e4t in den Pairing-Modus.\n\n### So rufst du den Pairing-Modus auf:\n1. Beende die SNOOZ Mobile-Apps zwangsweise.\n2. Dr\u00fccke und halte die Einschalttaste am Ger\u00e4t. Lasse sie los, wenn die Lichter zu blinken beginnen (ca. 5 Sekunden)." + }, + "step": { + "bluetooth_confirm": { + "description": "M\u00f6chtest du {name} einrichten?" + }, + "pairing_timeout": { + "description": "Das Ger\u00e4t konnte nicht in den Pairing-Modus wechseln. Klicke auf Senden, um es erneut zu versuchen.\n\n### Fehlerbehebung\n1. Stelle sicher, dass das Ger\u00e4t nicht mit der mobilen App verbunden ist.\n2. Trenne das Ger\u00e4t f\u00fcr 5 Sekunden vom Stromnetz und schlie\u00dfe es dann wieder an." + }, + "user": { + "data": { + "address": "Ger\u00e4t" + }, + "description": "W\u00e4hle ein Ger\u00e4t zum Einrichten aus" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/en.json b/homeassistant/components/snooz/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..a536a87be5b539e6305aa347bb662530d81565b8 --- /dev/null +++ b/homeassistant/components/snooz/translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "no_devices_found": "No devices found on the network" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "To complete setup, put this device in pairing mode.\n\n### How to enter pairing mode\n1. Force quit SNOOZ mobile apps.\n2. Press and hold the power button on the device. Release when the lights start blinking (approximately 5 seconds)." + }, + "step": { + "bluetooth_confirm": { + "description": "Do you want to setup {name}?" + }, + "pairing_timeout": { + "description": "The device did not enter pairing mode. Click Submit to try again.\n\n### Troubleshooting\n1. Check that the device isn't connected to the mobile app.\n2. Unplug the device for 5 seconds, then plug it back in." + }, + "user": { + "data": { + "address": "Device" + }, + "description": "Choose a device to setup" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/es.json b/homeassistant/components/snooz/translations/es.json new file mode 100644 index 0000000000000000000000000000000000000000..e19e5a2af15a6075610bcaceec96611f3d908c1d --- /dev/null +++ b/homeassistant/components/snooz/translations/es.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en curso", + "no_devices_found": "No se encontraron dispositivos en la red" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "Para completar la configuraci\u00f3n, pon este dispositivo en modo de emparejamiento. \n\n### C\u00f3mo ingresar al modo de emparejamiento\n1. Fuerza el cierre de las aplicaciones m\u00f3viles de SNOOZ.\n2. Manten pulsado el bot\u00f3n de encendido del dispositivo. Suelta cuando las luces comiencen a parpadear (aproximadamente 5 segundos)." + }, + "step": { + "bluetooth_confirm": { + "description": "\u00bfQuieres configurar {name}?" + }, + "pairing_timeout": { + "description": "El dispositivo no entr\u00f3 al modo de emparejamiento. Haz clic en Enviar para volver a intentarlo. \n\n### Soluci\u00f3n de problemas\n1. Verifica que el dispositivo no est\u00e9 conectado a la aplicaci\u00f3n m\u00f3vil.\n2. Desenchufa el dispositivo durante 5 segundos y luego vuelve a enchufarlo." + }, + "user": { + "data": { + "address": "Dispositivo" + }, + "description": "Elige un dispositivo para configurar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/et.json b/homeassistant/components/snooz/translations/et.json new file mode 100644 index 0000000000000000000000000000000000000000..53ec0cec70b6578aaee2213178d9a5da7bf8cf1c --- /dev/null +++ b/homeassistant/components/snooz/translations/et.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "already_in_progress": "Seadistamine on juba k\u00e4imas", + "no_devices_found": "V\u00f5rgust seadmeid ei leitud" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "Seadistamise l\u00f5petamiseks l\u00fclita see seade sidumisre\u017eiimi. \n\n ### Sidumisre\u017eiimi sisenemine\n 1. Sunni SNOOZi mobiilirakendustest v\u00e4ljuma.\n 2. Vajuta ja hoia all seadme toitenuppu. Vabasta kui tuled hakkavad vilkuma (umbes 5 sekundit)." + }, + "step": { + "bluetooth_confirm": { + "description": "Kas seadistada {name}?" + }, + "pairing_timeout": { + "description": "Seade ei sisenenud sidumisre\u017eiimi. Uuesti proovimiseks kl\u00f5psa nuppu Esita. \n\n ### Veaotsing\n 1. Veendu, et seade pole mobiilirakendusega \u00fchendatud.\n 2. \u00dchenda seade 5 sekundiks lahti, seej\u00e4rel \u00fchenda see uuesti." + }, + "user": { + "data": { + "address": "Seade" + }, + "description": "Vali h\u00e4\u00e4lestatav seade" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/fr.json b/homeassistant/components/snooz/translations/fr.json new file mode 100644 index 0000000000000000000000000000000000000000..c8a1af034cf7e7ca61feedc98d058087259a5d70 --- /dev/null +++ b/homeassistant/components/snooz/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Voulez-vous configurer {name}\u00a0?" + }, + "user": { + "data": { + "address": "Appareil" + }, + "description": "S\u00e9lectionnez l'appareil \u00e0 configurer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/he.json b/homeassistant/components/snooz/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..de780eb221ab27344e979ccb6033be518ec02711 --- /dev/null +++ b/homeassistant/components/snooz/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {name}?" + }, + "user": { + "data": { + "address": "\u05d4\u05ea\u05e7\u05df" + }, + "description": "\u05d1\u05d7\u05d9\u05e8\u05ea \u05d4\u05ea\u05e7\u05df \u05dc\u05d4\u05d2\u05d3\u05e8\u05d4" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/hu.json b/homeassistant/components/snooz/translations/hu.json new file mode 100644 index 0000000000000000000000000000000000000000..900b8670fa3610dc6367595ced34097907319e92 --- /dev/null +++ b/homeassistant/components/snooz/translations/hu.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A be\u00e1ll\u00edt\u00e1si folyamat m\u00e1r el lett kezdve", + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "A be\u00e1ll\u00edt\u00e1s befejez\u00e9s\u00e9hez \u00e1ll\u00edtsa a k\u00e9sz\u00fcl\u00e9ket p\u00e1ros\u00edt\u00e1si m\u00f3dba.\n\n### Hogyan l\u00e9phet be a p\u00e1ros\u00edt\u00e1si m\u00f3dba\n1. A SNOOZ mobilalkalmaz\u00e1sokat k\u00e9nyszer\u00edtve \u00e1ll\u00edtsa le.\n2. Nyomja meg \u00e9s tartsa lenyomva a k\u00e9sz\u00fcl\u00e9k bekapcsol\u00f3gombj\u00e1t. Engedje el, amikor a f\u00e9nyek villogni kezdenek (k\u00f6r\u00fclbel\u00fcl 5 m\u00e1sodperc)." + }, + "step": { + "bluetooth_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?" + }, + "pairing_timeout": { + "description": "A k\u00e9sz\u00fcl\u00e9k nem l\u00e9pett p\u00e1ros\u00edt\u00e1si m\u00f3dba. Kattintson a K\u00fcld\u00e9s gombra az \u00fajb\u00f3li pr\u00f3b\u00e1lkoz\u00e1shoz.\n\n### Hibaelh\u00e1r\u00edt\u00e1s\n1. Ellen\u0151rizze, hogy a k\u00e9sz\u00fcl\u00e9k nincs-e csatlakoztatva a mobilalkalmaz\u00e1shoz.\n2. H\u00fazza ki a k\u00e9sz\u00fcl\u00e9ket 5 m\u00e1sodpercre, majd dugja vissza." + }, + "user": { + "data": { + "address": "Eszk\u00f6z" + }, + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/id.json b/homeassistant/components/snooz/translations/id.json new file mode 100644 index 0000000000000000000000000000000000000000..a62bff72f1dbfaf606b4002333911f70bd93bb00 --- /dev/null +++ b/homeassistant/components/snooz/translations/id.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "Untuk menyelesaikan penyiapan, siapkan perangkat ini dalam mode pairing.\n\n### Cara memasuki mode pairing\n1. Keluar paksa dari aplikasi seluler SNOOZ.\n2. Tekan dan tahan tombol daya pada perangkat. Lepaskan saat lampu mulai berkedip (kira-kira 5 detik)." + }, + "step": { + "bluetooth_confirm": { + "description": "Ingin menyiapkan {name}?" + }, + "pairing_timeout": { + "description": "Perangkat tidak masuk ke mode pairing. Klik Kirim untuk mencoba lagi.\n\n### Pemecahan Masalah\n1. Periksa apakah perangkat sudah tidak terhubung ke aplikasi seluler.\n2. Cabut perangkat selama 5 detik, kemudian colokkan kembali." + }, + "user": { + "data": { + "address": "Perangkat" + }, + "description": "Pilih perangkat untuk disiapkan" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/it.json b/homeassistant/components/snooz/translations/it.json new file mode 100644 index 0000000000000000000000000000000000000000..7f3dc5b819140fa09b701e6539899807b423fbdd --- /dev/null +++ b/homeassistant/components/snooz/translations/it.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "no_devices_found": "Nessun dispositivo trovato sulla rete" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "Per completare la configurazione, metti questo dispositivo in modalit\u00e0 di associazione. \n\n ### Come accedere alla modalit\u00e0 di associazione\n 1. Uscita forzata dalle app mobili di SNOOZ.\n 2. Tenere premuto il pulsante di accensione sul dispositivo. Rilasciare quando le luci iniziano a lampeggiare (circa 5 secondi)." + }, + "step": { + "bluetooth_confirm": { + "description": "Vuoi configurare {name}?" + }, + "pairing_timeout": { + "description": "Il dispositivo non \u00e8 entrato in modalit\u00e0 di associazione. Fai clic su Invia per riprovare.\n\n### Risoluzione dei problemi\n1. Verifica che il dispositivo non sia connesso all'app mobile.\n2. Scollegare il dispositivo per 5 secondi, quindi ricollegarlo." + }, + "user": { + "data": { + "address": "Dispositivo" + }, + "description": "Seleziona un dispositivo da configurare" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/ja.json b/homeassistant/components/snooz/translations/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..38f862bd2f6d8205f2a35bced33e72d4580d2d8a --- /dev/null +++ b/homeassistant/components/snooz/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", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306b\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "{name} \u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "address": "\u30c7\u30d0\u30a4\u30b9" + }, + "description": "\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3059\u308b\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/nb.json b/homeassistant/components/snooz/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..6ba5a1f39783f4c2bf505ed9ee81367229b702eb --- /dev/null +++ b/homeassistant/components/snooz/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/nl.json b/homeassistant/components/snooz/translations/nl.json new file mode 100644 index 0000000000000000000000000000000000000000..a46f954fe5f25c44a803ea6c3ef25154424690e4 --- /dev/null +++ b/homeassistant/components/snooz/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratie is momenteel al bezig", + "no_devices_found": "Geen apparaten gevonden op het netwerk" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Wilt u {name} instellen?" + }, + "user": { + "data": { + "address": "Apparaat" + }, + "description": "Kies een apparaat om in te stellen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/no.json b/homeassistant/components/snooz/translations/no.json new file mode 100644 index 0000000000000000000000000000000000000000..c16e7ce6d94e93cea8fe50b8c1b4377b158ab6e5 --- /dev/null +++ b/homeassistant/components/snooz/translations/no.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "For \u00e5 fullf\u00f8re oppsettet, sett denne enheten i sammenkoblingsmodus. \n\n ### Hvordan g\u00e5 inn i paringsmodus\n 1. Tving avslutning av SNOOZ-mobilapper.\n 2. Trykk og hold inne str\u00f8mknappen p\u00e5 enheten. Slipp n\u00e5r lysene begynner \u00e5 blinke (omtrent 5 sekunder)." + }, + "step": { + "bluetooth_confirm": { + "description": "Vil du konfigurere {name}?" + }, + "pairing_timeout": { + "description": "Enheten gikk ikke i sammenkoblingsmodus. Klikk p\u00e5 Send for \u00e5 pr\u00f8ve igjen. \n\n ### Feils\u00f8king\n 1. Sjekk at enheten ikke er koblet til mobilappen.\n 2. Koble fra enheten i 5 sekunder, og koble den deretter til igjen." + }, + "user": { + "data": { + "address": "Enhet" + }, + "description": "Velg en enhet du vil konfigurere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/pl.json b/homeassistant/components/snooz/translations/pl.json new file mode 100644 index 0000000000000000000000000000000000000000..dfb76c73eb53cfe9fe2ca5ca00e7d80d486e67da --- /dev/null +++ b/homeassistant/components/snooz/translations/pl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_in_progress": "Konfiguracja jest ju\u017c w toku", + "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "Aby zako\u0144czy\u0107 konfiguracj\u0119, prze\u0142\u0105cz to urz\u0105dzenie w tryb parowania. \n\n### Jak wej\u015b\u0107 w tryb parowania\n1. Wymu\u015b zamkni\u0119cie aplikacji mobilnych SNOOZ.\n2. Naci\u015bnij i przytrzymaj przycisk zasilania na urz\u0105dzeniu. Zwolnij, gdy kontrolki zaczn\u0105 miga\u0107 (oko\u0142o 5 sekund)." + }, + "step": { + "bluetooth_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {name}?" + }, + "pairing_timeout": { + "description": "Urz\u0105dzenie nie wesz\u0142o w tryb parowania. Kliknij Zatwierd\u017a, aby spr\u00f3bowa\u0107 ponownie. \n\n### Rozwi\u0105zywanie problem\u00f3w\n1. Sprawd\u017a, czy urz\u0105dzenie nie jest po\u0142\u0105czone z aplikacj\u0105 mobiln\u0105.\n2. Od\u0142\u0105cz urz\u0105dzenie na 5 sekund, a nast\u0119pnie pod\u0142\u0105cz je ponownie." + }, + "user": { + "data": { + "address": "Urz\u0105dzenie" + }, + "description": "Wybierz urz\u0105dzenie do skonfigurowania" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/pt-BR.json b/homeassistant/components/snooz/translations/pt-BR.json new file mode 100644 index 0000000000000000000000000000000000000000..a9bf79dad4e2706b8cb218846e0a35cbf85fc52d --- /dev/null +++ b/homeassistant/components/snooz/translations/pt-BR.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", + "no_devices_found": "Nenhum dispositivo encontrado na rede" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "Para concluir a configura\u00e7\u00e3o, coloque este dispositivo no modo de emparelhamento. \n\n ### Como entrar no modo de emparelhamento\n 1. For\u00e7ar o encerramento dos aplicativos m\u00f3veis SNOOZ.\n 2. Pressione e segure o bot\u00e3o liga/desliga no dispositivo. Solte quando as luzes come\u00e7arem a piscar (aproximadamente 5 segundos)." + }, + "step": { + "bluetooth_confirm": { + "description": "Deseja configurar {name}?" + }, + "pairing_timeout": { + "description": "O dispositivo n\u00e3o entrou no modo de emparelhamento. Clique em Enviar para tentar novamente. \n\n ### Solu\u00e7\u00e3o de problemas\n 1. Verifique se o dispositivo n\u00e3o est\u00e1 conectado ao aplicativo m\u00f3vel.\n 2. Desconecte o dispositivo por 5 segundos e conecte-o novamente." + }, + "user": { + "data": { + "address": "Dispositivo" + }, + "description": "Escolha um dispositivo para configurar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/ru.json b/homeassistant/components/snooz/translations/ru.json new file mode 100644 index 0000000000000000000000000000000000000000..13b6c954ad96aca1a540ac466846c8456b89cad4 --- /dev/null +++ b/homeassistant/components/snooz/translations/ru.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438." + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "\u0427\u0442\u043e\u0431\u044b \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443, \u043f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 \u044d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432 \u0440\u0435\u0436\u0438\u043c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f. \n\n### \u041a\u0430\u043a \u0432\u043e\u0439\u0442\u0438 \u0432 \u0440\u0435\u0436\u0438\u043c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f\n1. \u041f\u0440\u0438\u043d\u0443\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u0437\u0430\u043a\u0440\u043e\u0439\u0442\u0435 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u044b\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f SNOOZ.\n2. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0438 \u0443\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0439\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u043f\u0438\u0442\u0430\u043d\u0438\u044f \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435. \u041e\u0442\u043f\u0443\u0441\u0442\u0438\u0442\u0435, \u043a\u043e\u0433\u0434\u0430 \u0438\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u044b \u043d\u0430\u0447\u043d\u0443\u0442 \u043c\u0438\u0433\u0430\u0442\u044c (\u043f\u0440\u0438\u043c\u0435\u0440\u043d\u043e 5 \u0441\u0435\u043a\u0443\u043d\u0434)." + }, + "step": { + "bluetooth_confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name}?" + }, + "pairing_timeout": { + "description": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u043f\u0435\u0440\u0435\u0448\u043b\u043e \u0432 \u0440\u0435\u0436\u0438\u043c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c\u00bb, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u044c \u043f\u043e\u043f\u044b\u0442\u043a\u0443. \n\n### \u0418\u0441\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\n1. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043a \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u043c\u0443 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044e.\n2. \u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442 \u0441\u0435\u0442\u0438 \u043d\u0430 5 \u0441\u0435\u043a\u0443\u043d\u0434, \u0437\u0430\u0442\u0435\u043c \u0441\u043d\u043e\u0432\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0435." + }, + "user": { + "data": { + "address": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/sv.json b/homeassistant/components/snooz/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..ab14fbbddb65629484ae5ee5e8a6ab9c9c687312 --- /dev/null +++ b/homeassistant/components/snooz/translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad", + "already_in_progress": "Konfigurationsfl\u00f6det p\u00e5g\u00e5r redan", + "no_devices_found": "Inga enheter hittades i n\u00e4tverket" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "address": "Enhet" + }, + "description": "V\u00e4lj en enhet att konfigurera" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/tr.json b/homeassistant/components/snooz/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..8b4d9cc646c7497fe96bc9ab76510ba296deff62 --- /dev/null +++ b/homeassistant/components/snooz/translations/tr.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "no_devices_found": "A\u011fda cihaz bulunamad\u0131" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "Kurulumu tamamlamak i\u00e7in bu cihaz\u0131 e\u015fle\u015ftirme moduna al\u0131n. \n\n ### E\u015fle\u015ftirme moduna nas\u0131l girilir\n 1. SNOOZ mobil uygulamalar\u0131ndan \u00e7\u0131kmaya zorlay\u0131n.\n 2. Cihazdaki g\u00fc\u00e7 d\u00fc\u011fmesini bas\u0131l\u0131 tutun. I\u015f\u0131klar yan\u0131p s\u00f6nmeye ba\u015flad\u0131\u011f\u0131nda b\u0131rak\u0131n (yakla\u015f\u0131k 5 saniye)." + }, + "step": { + "bluetooth_confirm": { + "description": "{name} kurulumunu yapmak istiyor musunuz?" + }, + "pairing_timeout": { + "description": "Cihaz e\u015fle\u015ftirme moduna girmedi. Tekrar denemek i\u00e7in G\u00f6nder'i t\u0131klay\u0131n. \n\n ### Sorun giderme\n 1. Cihaz\u0131n mobil uygulamaya ba\u011fl\u0131 olmad\u0131\u011f\u0131n\u0131 kontrol edin.\n 2. Ayg\u0131t\u0131n fi\u015fini 5 saniyeli\u011fine \u00e7ekin, ard\u0131ndan tekrar tak\u0131n." + }, + "user": { + "data": { + "address": "Cihaz" + }, + "description": "Kurulum i\u00e7in bir cihaz se\u00e7in" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snooz/translations/zh-Hant.json b/homeassistant/components/snooz/translations/zh-Hant.json new file mode 100644 index 0000000000000000000000000000000000000000..adfec147839ee0dd316c5fd9c3fdf9c1e0c27e51 --- /dev/null +++ b/homeassistant/components/snooz/translations/zh-Hant.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e" + }, + "flow_title": "{name}", + "progress": { + "wait_for_pairing_mode": "\u6b32\u5b8c\u6210\u8a2d\u5b9a\u3001\u5148\u8b93\u88dd\u7f6e\u9032\u5165\u914d\u5c0d\u6a21\u5f0f\u3002\n\n### \u5982\u4f55\u9032\u5165\u914d\u5c0d\u6a21\u5f0f\n1. \u5f37\u5236\u9000\u51fa SNOOZ \u624b\u6a5f App\u3002\n2. \u6309\u4f4f\u88dd\u7f6e\u4e0a\u7684\u96fb\u6e90\u9375\u4e0d\u653e\u3001\u7576\u71c8\u865f\u958b\u59cb\u9583\u8000\u6642\u653e\u958b\uff08\u5927\u7d04 5 \u79d2\uff09\u3002" + }, + "step": { + "bluetooth_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name}\uff1f" + }, + "pairing_timeout": { + "description": "\u88dd\u7f6e\u4e26\u672a\u9032\u5165\u914d\u5c0d\u6a21\u5f0f\u3001\u9ede\u9078\u50b3\u9001\u518d\u8a66\u4e00\u6b21\u3002\n\n### \u554f\u984c\u6392\u9664\n1. \u6aa2\u67e5\u88dd\u7f6e\u4e26\u672a\u8207\u624b\u6a5f App \u9023\u7dda\u3002\n2. \u62d4\u4e0b\u88dd\u7f6e\u7b49\u5019 5 \u79d2\u3001\u7136\u5f8c\u518d\u63d2\u4e0a\u3002" + }, + "user": { + "data": { + "address": "\u88dd\u7f6e" + }, + "description": "\u9078\u64c7\u6240\u8981\u8a2d\u5b9a\u7684\u88dd\u7f6e" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/manifest.json b/homeassistant/components/solaredge/manifest.json index e5c9520f96b84e856e38157aa879eb77ccf4ef57..0bd5be1eaec3fc3647d12c6fc75a52e5d58baee7 100644 --- a/homeassistant/components/solaredge/manifest.json +++ b/homeassistant/components/solaredge/manifest.json @@ -12,5 +12,6 @@ } ], "iot_class": "cloud_polling", + "integration_type": "device", "loggers": ["solaredge"] } diff --git a/homeassistant/components/solax/translations/nb.json b/homeassistant/components/solax/translations/nb.json index 66b7784c3fc7ced523b64ef026b3ee287dffe266..f092dc69c22aba3d2975ea5dd25f1b3e22fee2f1 100644 --- a/homeassistant/components/solax/translations/nb.json +++ b/homeassistant/components/solax/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/somfy/manifest.json b/homeassistant/components/somfy/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..3090eb7b120d6a0aa483e4e4f11ae8df6e7722bf --- /dev/null +++ b/homeassistant/components/somfy/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "somfy", + "name": "Somfy", + "integration_type": "virtual", + "supported_by": "overkiz" +} diff --git a/homeassistant/components/somfy/translations/bg.json b/homeassistant/components/somfy/translations/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..62905ef389ee2907d4a4b5df36d29268e2e7d8a4 --- /dev/null +++ b/homeassistant/components/somfy/translations/bg.json @@ -0,0 +1,17 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..bc34c57c939559bb9b2fa69ed5847d42e66083a5 --- /dev/null +++ b/homeassistant/components/somfy/translations/ca.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..acc7d260cad69f76ba5e3d47c5abe2d27c31c9f3 --- /dev/null +++ b/homeassistant/components/somfy/translations/cs.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..3b7a79ef008f8f834d72c51580265d362eae0fdb --- /dev/null +++ b/homeassistant/components/somfy/translations/da.json @@ -0,0 +1,16 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..29a959f48ce140a2eabb81ba12e578b2ff37930d --- /dev/null +++ b/homeassistant/components/somfy/translations/de.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..8d1f457ae10faacde5e492ca542efe6e8346f756 --- /dev/null +++ b/homeassistant/components/somfy/translations/el.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..e0072d1da4daa0656309f41a5c33df03db2cbee9 --- /dev/null +++ b/homeassistant/components/somfy/translations/en.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..ddf7ee6d5dd7a817cee623b8b0b4c925ce92cb93 --- /dev/null +++ b/homeassistant/components/somfy/translations/en_GB.json @@ -0,0 +1,7 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..6acd9bb6bb8e8c8f6b1a34a0b9fdc4a6de68a720 --- /dev/null +++ b/homeassistant/components/somfy/translations/es-419.json @@ -0,0 +1,16 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..db3edbd35fda89a4c71c69fb995f5a9aaacf3870 --- /dev/null +++ b/homeassistant/components/somfy/translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Se agot\u00f3 el tiempo de espera para generar la URL de autorizaci\u00f3n.", + "missing_configuration": "El componente no est\u00e1 configurado. Por favor, sigue la documentaci\u00f3n.", + "no_url_available": "No hay URL disponible. Para obtener informaci\u00f3n sobre este error, [revisa 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": "Selecciona 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 new file mode 100644 index 0000000000000000000000000000000000000000..9239f7df0ef25098b1b2b4b645689515a8c33d85 --- /dev/null +++ b/homeassistant/components/somfy/translations/et.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..0c7a25831bcd1633ba47adb57a7fb64d9ea29dc8 --- /dev/null +++ b/homeassistant/components/somfy/translations/fr.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..c68d7f74d85f9e432e6286b588db062824ea761e --- /dev/null +++ b/homeassistant/components/somfy/translations/he.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..a601eb2b9bf660f85bd45874dd0b2ed54e05adc7 --- /dev/null +++ b/homeassistant/components/somfy/translations/hr.json @@ -0,0 +1,7 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..96b873b2c42105ffe6d6c7adb39e264bc85cb7dc --- /dev/null +++ b/homeassistant/components/somfy/translations/hu.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..2d229de00d579f2cedb170d7e7dc86c1867ab853 --- /dev/null +++ b/homeassistant/components/somfy/translations/id.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..0201e1e25691a168db496c62c7fb42ef021f9984 --- /dev/null +++ b/homeassistant/components/somfy/translations/it.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..365d3e4b0db37c79492e2363d5964c770f756fef --- /dev/null +++ b/homeassistant/components/somfy/translations/ja.json @@ -0,0 +1,18 @@ +{ + "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\u8a2d\u5b9a\u3067\u304d\u308b\u306e\u306f1\u3064\u3060\u3051\u3067\u3059\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 new file mode 100644 index 0000000000000000000000000000000000000000..568c8d051163da7eba10b916d52bd082363ded2b --- /dev/null +++ b/homeassistant/components/somfy/translations/ko.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..a463473c2e168d6dc40125cabce3129c21d862f7 --- /dev/null +++ b/homeassistant/components/somfy/translations/lb.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..efd07952467c1328013401ebb62aec0786211093 --- /dev/null +++ b/homeassistant/components/somfy/translations/nl.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..57bc6e684360907d402a968b98f0a068e912dacd --- /dev/null +++ b/homeassistant/components/somfy/translations/no.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..baeb38e755e1a118974674a0ebbe3f1f064901e9 --- /dev/null +++ b/homeassistant/components/somfy/translations/pl.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..8ad5fac904490d76e0d3db5226c136bc754c77b8 --- /dev/null +++ b/homeassistant/components/somfy/translations/pt-BR.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..592ccd855895104ece10a1336d049792c9e8632f --- /dev/null +++ b/homeassistant/components/somfy/translations/pt.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..38ac0dda41256ddeb95d5d213ad7c1bc7b37473f --- /dev/null +++ b/homeassistant/components/somfy/translations/ru.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..c19b1a0b70c7071e8c0e436e45cba5f0591a5706 --- /dev/null +++ b/homeassistant/components/somfy/translations/sk.json @@ -0,0 +1,7 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..3b9bc038fe6023c597dce2463a61a2d4d4c05db2 --- /dev/null +++ b/homeassistant/components/somfy/translations/sl.json @@ -0,0 +1,16 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..d011a16d90bbf48cbbbdea5a4183cdb1c6cfd86b --- /dev/null +++ b/homeassistant/components/somfy/translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout vid skapandet av en auktoriseringsadress.", + "missing_configuration": "Somfy-komponenten \u00e4r inte konfigurerad. V\u00e4nligen f\u00f6lj dokumentationen.", + "no_url_available": "Ingen webbadress tillg\u00e4nglig. F\u00f6r information om detta fel, [kolla hj\u00e4lpavsnittet]({docs_url})", + "single_instance_allowed": "Redan konfigurerad. Endast en konfiguration m\u00f6jlig." + }, + "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 new file mode 100644 index 0000000000000000000000000000000000000000..b3b645cd52d9c969f71345aac7708aefb71f423e --- /dev/null +++ b/homeassistant/components/somfy/translations/tr.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..207169ad6b0ae11404de637741f4450630b40f91 --- /dev/null +++ b/homeassistant/components/somfy/translations/uk.json @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..8dccd6771cb13e5fbf88efb5539529b4519a4ca6 --- /dev/null +++ b/homeassistant/components/somfy/translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "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/translations/nb.json b/homeassistant/components/somfy_mylink/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/somfy_mylink/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 4447425f42a26ba945ae18f6ba8a3746171c9287..c592e8435c28b8ea7f22b41bc2b6f515fbafa82d 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -1,10 +1,8 @@ """The Sonarr component.""" from __future__ import annotations -from datetime import timedelta -import logging +from typing import Any -from aiopyarr import ArrAuthenticationException, ArrException from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.sonarr_client import SonarrClient @@ -19,24 +17,29 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_BASE_PATH, CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, - DATA_HOST_CONFIG, - DATA_SONARR, - DATA_SYSTEM_STATUS, DEFAULT_UPCOMING_DAYS, DEFAULT_WANTED_MAX_ITEMS, DOMAIN, + LOGGER, +) +from .coordinator import ( + CalendarDataUpdateCoordinator, + CommandsDataUpdateCoordinator, + DiskSpaceDataUpdateCoordinator, + QueueDataUpdateCoordinator, + SeriesDataUpdateCoordinator, + SonarrDataUpdateCoordinator, + StatusDataUpdateCoordinator, + WantedDataUpdateCoordinator, ) PLATFORMS = [Platform.SENSOR] -SCAN_INTERVAL = timedelta(seconds=30) -_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -57,30 +60,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: url=entry.data[CONF_URL], verify_ssl=entry.data[CONF_VERIFY_SSL], ) - sonarr = SonarrClient( host_configuration=host_configuration, session=async_get_clientsession(hass), ) - - try: - system_status = await sonarr.async_get_system_status() - except ArrAuthenticationException as err: - raise ConfigEntryAuthFailed( - "API Key is no longer valid. Please reauthenticate" - ) from err - except ArrException as err: - raise ConfigEntryNotReady from err - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_HOST_CONFIG: host_configuration, - DATA_SONARR: sonarr, - DATA_SYSTEM_STATUS: system_status, + coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = { + "upcoming": CalendarDataUpdateCoordinator(hass, host_configuration, sonarr), + "commands": CommandsDataUpdateCoordinator(hass, host_configuration, sonarr), + "diskspace": DiskSpaceDataUpdateCoordinator(hass, host_configuration, sonarr), + "queue": QueueDataUpdateCoordinator(hass, host_configuration, sonarr), + "series": SeriesDataUpdateCoordinator(hass, host_configuration, sonarr), + "status": StatusDataUpdateCoordinator(hass, host_configuration, sonarr), + "wanted": WantedDataUpdateCoordinator(hass, host_configuration, sonarr), } - + # Temporary, until we add diagnostic entities + _version = None + for coordinator in coordinators.values(): + await coordinator.async_config_entry_first_refresh() + if isinstance(coordinator, StatusDataUpdateCoordinator): + _version = coordinator.data.version + coordinator.system_version = _version + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -88,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", entry.version) + LOGGER.debug("Migrating from version %s", entry.version) if entry.version == 1: new_proto = "https" if entry.data[CONF_SSL] else "http" @@ -106,7 +107,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, data=data) entry.version = 2 - _LOGGER.info("Migration to version %s successful", entry.version) + LOGGER.info("Migration to version %s successful", entry.version) return True diff --git a/homeassistant/components/sonarr/const.py b/homeassistant/components/sonarr/const.py index 58f5c4657168a6c0c44e43997b6e9de698a58af6..5468953184a1964b00330a8ad1f10a6814b2dbf5 100644 --- a/homeassistant/components/sonarr/const.py +++ b/homeassistant/components/sonarr/const.py @@ -1,4 +1,6 @@ """Constants for Sonarr.""" +import logging + DOMAIN = "sonarr" # Config Keys @@ -9,12 +11,10 @@ CONF_UNIT = "unit" CONF_UPCOMING_DAYS = "upcoming_days" CONF_WANTED_MAX_ITEMS = "wanted_max_items" -# Data -DATA_HOST_CONFIG = "host_config" -DATA_SONARR = "sonarr" -DATA_SYSTEM_STATUS = "system_status" - # Defaults +DEFAULT_NAME = "Sonarr" DEFAULT_UPCOMING_DAYS = 1 DEFAULT_VERIFY_SSL = False DEFAULT_WANTED_MAX_ITEMS = 50 + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/sonarr/coordinator.py b/homeassistant/components/sonarr/coordinator.py new file mode 100644 index 0000000000000000000000000000000000000000..9b9a06b15f8ce7875d8cacefdfc2380ef2687c40 --- /dev/null +++ b/homeassistant/components/sonarr/coordinator.py @@ -0,0 +1,147 @@ +"""Data update coordinator for the Sonarr integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import TypeVar, Union, cast + +from aiopyarr import ( + Command, + Diskspace, + SonarrCalendar, + SonarrQueue, + SonarrSeries, + SonarrWantedMissing, + SystemStatus, + exceptions, +) +from aiopyarr.models.host_configuration import PyArrHostConfiguration +from aiopyarr.sonarr_client import SonarrClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, DOMAIN, LOGGER + +SonarrDataT = TypeVar( + "SonarrDataT", + bound=Union[ + list[SonarrCalendar], + list[Command], + list[Diskspace], + SonarrQueue, + list[SonarrSeries], + SystemStatus, + SonarrWantedMissing, + ], +) + + +class SonarrDataUpdateCoordinator(DataUpdateCoordinator[SonarrDataT]): + """Data update coordinator for the Sonarr integration.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + host_configuration: PyArrHostConfiguration, + api_client: SonarrClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.api_client = api_client + self.host_configuration = host_configuration + self.system_version: str | None = None + + async def _async_update_data(self) -> SonarrDataT: + """Get the latest data from Sonarr.""" + try: + return await self._fetch_data() + + except exceptions.ArrConnectionException as ex: + raise UpdateFailed(ex) from ex + except exceptions.ArrAuthenticationException as ex: + raise ConfigEntryAuthFailed( + "API Key is no longer valid. Please reauthenticate" + ) from ex + + async def _fetch_data(self) -> SonarrDataT: + """Fetch the actual data.""" + raise NotImplementedError + + +class CalendarDataUpdateCoordinator(SonarrDataUpdateCoordinator[list[SonarrCalendar]]): + """Calendar update coordinator.""" + + async def _fetch_data(self) -> list[SonarrCalendar]: + """Fetch the movies data.""" + local = dt_util.start_of_local_day().replace(microsecond=0) + start = dt_util.as_utc(local) + end = start + timedelta(days=self.config_entry.options[CONF_UPCOMING_DAYS]) + return cast( + list[SonarrCalendar], + await self.api_client.async_get_calendar( + start_date=start, end_date=end, include_series=True + ), + ) + + +class CommandsDataUpdateCoordinator(SonarrDataUpdateCoordinator[list[Command]]): + """Commands update coordinator for Sonarr.""" + + async def _fetch_data(self) -> list[Command]: + """Fetch the data.""" + return cast(list[Command], await self.api_client.async_get_commands()) + + +class DiskSpaceDataUpdateCoordinator(SonarrDataUpdateCoordinator[list[Diskspace]]): + """Disk space update coordinator for Sonarr.""" + + async def _fetch_data(self) -> list[Diskspace]: + """Fetch the data.""" + return await self.api_client.async_get_diskspace() + + +class QueueDataUpdateCoordinator(SonarrDataUpdateCoordinator[SonarrQueue]): + """Queue update coordinator.""" + + async def _fetch_data(self) -> SonarrQueue: + """Fetch the data.""" + return await self.api_client.async_get_queue( + include_series=True, include_episode=True + ) + + +class SeriesDataUpdateCoordinator(SonarrDataUpdateCoordinator[list[SonarrSeries]]): + """Series update coordinator.""" + + async def _fetch_data(self) -> list[SonarrSeries]: + """Fetch the data.""" + return cast(list[SonarrSeries], await self.api_client.async_get_series()) + + +class StatusDataUpdateCoordinator(SonarrDataUpdateCoordinator[SystemStatus]): + """Status update coordinator for Sonarr.""" + + async def _fetch_data(self) -> SystemStatus: + """Fetch the data.""" + return await self.api_client.async_get_system_status() + + +class WantedDataUpdateCoordinator(SonarrDataUpdateCoordinator[SonarrWantedMissing]): + """Wanted update coordinator.""" + + async def _fetch_data(self) -> SonarrWantedMissing: + """Fetch the data.""" + return await self.api_client.async_get_wanted( + page_size=self.config_entry.options[CONF_WANTED_MAX_ITEMS], + include_series=True, + ) diff --git a/homeassistant/components/sonarr/entity.py b/homeassistant/components/sonarr/entity.py index 41f6786503d17f3721e424eac218a652626b2deb..e8a65239be75fb1308a3e6dd3a33b87a46be2cac 100644 --- a/homeassistant/components/sonarr/entity.py +++ b/homeassistant/components/sonarr/entity.py @@ -1,46 +1,38 @@ """Base Entity for Sonarr.""" from __future__ import annotations -from aiopyarr import SystemStatus -from aiopyarr.models.host_configuration import PyArrHostConfiguration -from aiopyarr.sonarr_client import SonarrClient - from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import SonarrDataT, SonarrDataUpdateCoordinator -class SonarrEntity(Entity): +class SonarrEntity(CoordinatorEntity[SonarrDataUpdateCoordinator[SonarrDataT]]): """Defines a base Sonarr entity.""" + _attr_has_entity_name = True + def __init__( self, - *, - sonarr: SonarrClient, - host_config: PyArrHostConfiguration, - system_status: SystemStatus, - entry_id: str, - device_id: str, + coordinator: SonarrDataUpdateCoordinator[SonarrDataT], + description: EntityDescription, ) -> None: """Initialize the Sonarr entity.""" - self._entry_id = entry_id - self._device_id = device_id - self.sonarr = sonarr - self.host_config = host_config - self.system_status = system_status + super().__init__(coordinator) + self.coordinator = coordinator + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" @property - def device_info(self) -> DeviceInfo | None: + def device_info(self) -> DeviceInfo: """Return device information about the application.""" - if self._device_id is None: - return None - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - name="Activity Sensor", - manufacturer="Sonarr", - sw_version=self.system_status.version, + configuration_url=self.coordinator.host_configuration.base_url, entry_type=DeviceEntryType.SERVICE, - configuration_url=self.host_config.base_url, + identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, + manufacturer=DEFAULT_NAME, + name=DEFAULT_NAME, + sw_version=self.coordinator.system_version, ) diff --git a/homeassistant/components/sonarr/manifest.json b/homeassistant/components/sonarr/manifest.json index 0c5b68a794976e86401dc358b1b77782049a4c2e..daf9e20586b2677f65f99a1b4306520cf979fbf3 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.9.0"], + "requirements": ["aiopyarr==22.10.0"], "config_flow": true, "quality_scale": "silver", "iot_class": "local_polling", diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index adc588f6951b7cb35b1701edf184bace6cf1c2a2..fe440b01be0750558b4146a345179b082d808b33 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -1,16 +1,18 @@ """Support for Sonarr sensors.""" from __future__ import annotations -from collections.abc import Awaitable, Callable, Coroutine -from datetime import timedelta -from functools import wraps -import logging -from typing import Any, TypeVar - -from aiopyarr import ArrConnectionException, ArrException, SystemStatus -from aiopyarr.models.host_configuration import PyArrHostConfiguration -from aiopyarr.sonarr_client import SonarrClient -from typing_extensions import Concatenate, ParamSpec +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Generic + +from aiopyarr import ( + Command, + Diskspace, + SonarrCalendar, + SonarrQueue, + SonarrSeries, + SonarrWantedMissing, +) from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry @@ -20,64 +22,123 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType import homeassistant.util.dt as dt_util -from .const import ( - CONF_UPCOMING_DAYS, - CONF_WANTED_MAX_ITEMS, - DATA_HOST_CONFIG, - DATA_SONARR, - DATA_SYSTEM_STATUS, - DOMAIN, -) +from .const import DOMAIN +from .coordinator import SonarrDataT, SonarrDataUpdateCoordinator from .entity import SonarrEntity -_LOGGER = logging.getLogger(__name__) -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +@dataclass +class SonarrSensorEntityDescriptionMixIn(Generic[SonarrDataT]): + """Mixin for Sonarr sensor.""" + + attributes_fn: Callable[[SonarrDataT], dict[str, str]] + value_fn: Callable[[SonarrDataT], StateType] + + +@dataclass +class SonarrSensorEntityDescription( + SensorEntityDescription, SonarrSensorEntityDescriptionMixIn[SonarrDataT] +): + """Class to describe a Sonarr sensor.""" + + +def get_disk_space_attr(disks: list[Diskspace]) -> dict[str, str]: + """Create the attributes for disk space.""" + attrs: dict[str, str] = {} + for disk in disks: + free = disk.freeSpace / 1024**3 + total = disk.totalSpace / 1024**3 + usage = free / total * 100 + attrs[disk.path] = f"{free:.2f}/{total:.2f}{DATA_GIGABYTES} ({usage:.2f}%)" + return attrs + + +def get_queue_attr(queue: SonarrQueue) -> dict[str, str]: + """Create the attributes for series queue.""" + attrs: dict[str, str] = {} + for item in queue.records: + remaining = 1 if item.size == 0 else item.sizeleft / item.size + remaining_pct = 100 * (1 - remaining) + identifier = ( + f"S{item.episode.seasonNumber:02d}E{item.episode. episodeNumber:02d}" + ) + attrs[f"{item.series.title} {identifier}"] = f"{remaining_pct:.2f}%" + return attrs + + +def get_wanted_attr(wanted: SonarrWantedMissing) -> dict[str, str]: + """Create the attributes for missing series.""" + attrs: dict[str, str] = {} + for item in wanted.records: + identifier = f"S{item.seasonNumber:02d}E{item.episodeNumber:02d}" + + name = f"{item.series.title} {identifier}" + attrs[name] = dt_util.as_local( + item.airDateUtc.replace(tzinfo=dt_util.UTC) + ).isoformat() + return attrs + + +SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { + "commands": SonarrSensorEntityDescription[list[Command]]( key="commands", - name="Sonarr Commands", + name="Commands", icon="mdi:code-braces", native_unit_of_measurement="Commands", entity_registry_enabled_default=False, + value_fn=len, + attributes_fn=lambda data: {c.name: c.status for c in data}, ), - SensorEntityDescription( + "diskspace": SonarrSensorEntityDescription[list[Diskspace]]( key="diskspace", - name="Sonarr Disk Space", + name="Disk space", icon="mdi:harddisk", native_unit_of_measurement=DATA_GIGABYTES, entity_registry_enabled_default=False, + value_fn=lambda data: f"{sum(disk.freeSpace for disk in data) / 1024**3:.2f}", + attributes_fn=get_disk_space_attr, ), - SensorEntityDescription( + "queue": SonarrSensorEntityDescription[SonarrQueue]( key="queue", - name="Sonarr Queue", + name="Queue", icon="mdi:download", native_unit_of_measurement="Episodes", entity_registry_enabled_default=False, + value_fn=lambda data: data.totalRecords, + attributes_fn=get_queue_attr, ), - SensorEntityDescription( + "series": SonarrSensorEntityDescription[list[SonarrSeries]]( key="series", - name="Sonarr Shows", + name="Shows", icon="mdi:television", native_unit_of_measurement="Series", entity_registry_enabled_default=False, + value_fn=len, + attributes_fn=lambda data: { + i.title: f"{getattr(i.statistics,'episodeFileCount', 0)}/{getattr(i.statistics, 'episodeCount', 0)} Episodes" + for i in data + }, ), - SensorEntityDescription( + "upcoming": SonarrSensorEntityDescription[list[SonarrCalendar]]( key="upcoming", - name="Sonarr Upcoming", + name="Upcoming", icon="mdi:television", native_unit_of_measurement="Episodes", + value_fn=len, + attributes_fn=lambda data: { + e.series.title: f"S{e.seasonNumber:02d}E{e.episodeNumber:02d}" for e in data + }, ), - SensorEntityDescription( + "wanted": SonarrSensorEntityDescription[SonarrWantedMissing]( key="wanted", - name="Sonarr Wanted", + name="Wanted", icon="mdi:television", native_unit_of_measurement="Episodes", entity_registry_enabled_default=False, + value_fn=lambda data: data.totalRecords, + attributes_fn=get_wanted_attr, ), -) - -_SonarrSensorT = TypeVar("_SonarrSensorT", bound="SonarrSensor") -_P = ParamSpec("_P") +} async def async_setup_entry( @@ -86,196 +147,27 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Sonarr sensors based on a config entry.""" - sonarr: SonarrClient = hass.data[DOMAIN][entry.entry_id][DATA_SONARR] - host_config: PyArrHostConfiguration = hass.data[DOMAIN][entry.entry_id][ - DATA_HOST_CONFIG + coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = hass.data[DOMAIN][ + entry.entry_id ] - system_status: SystemStatus = hass.data[DOMAIN][entry.entry_id][DATA_SYSTEM_STATUS] - options: dict[str, Any] = dict(entry.options) - - entities = [ - SonarrSensor( - sonarr, - host_config, - system_status, - entry.entry_id, - description, - options, - ) - for description in SENSOR_TYPES - ] - - async_add_entities(entities, True) - - -def sonarr_exception_handler( - func: Callable[Concatenate[_SonarrSensorT, _P], Awaitable[None]] -) -> Callable[Concatenate[_SonarrSensorT, _P], Coroutine[Any, Any, None]]: - """Decorate Sonarr calls to handle Sonarr exceptions. + async_add_entities( + SonarrSensor(coordinators[coordinator_type], description) + for coordinator_type, description in SENSOR_TYPES.items() + ) - A decorator that wraps the passed in function, catches Sonarr errors, - and handles the availability of the entity. - """ - @wraps(func) - async def wrapper( - self: _SonarrSensorT, *args: _P.args, **kwargs: _P.kwargs - ) -> None: - try: - await func(self, *args, **kwargs) - self.last_update_success = True - except ArrConnectionException as error: - if self.last_update_success: - _LOGGER.error("Error communicating with API: %s", error) - self.last_update_success = False - except ArrException as error: - if self.last_update_success: - _LOGGER.error("Invalid response from API: %s", error) - self.last_update_success = False - - return wrapper - - -class SonarrSensor(SonarrEntity, SensorEntity): +class SonarrSensor(SonarrEntity[SonarrDataT], SensorEntity): """Implementation of the Sonarr sensor.""" - data: dict[str, Any] - last_update_success: bool - upcoming_days: int - wanted_max_items: int - - def __init__( - self, - sonarr: SonarrClient, - host_config: PyArrHostConfiguration, - system_status: SystemStatus, - entry_id: str, - description: SensorEntityDescription, - options: dict[str, Any], - ) -> None: - """Initialize Sonarr sensor.""" - self.entity_description = description - self._attr_unique_id = f"{entry_id}_{description.key}" - - self.data = {} - self.last_update_success = True - self.upcoming_days = options[CONF_UPCOMING_DAYS] - self.wanted_max_items = options[CONF_WANTED_MAX_ITEMS] - - super().__init__( - sonarr=sonarr, - host_config=host_config, - system_status=system_status, - entry_id=entry_id, - device_id=entry_id, - ) - - @property - def available(self) -> bool: - """Return sensor availability.""" - return self.last_update_success - - @sonarr_exception_handler - async def async_update(self) -> None: - """Update entity.""" - key = self.entity_description.key - - if key == "diskspace": - self.data[key] = await self.sonarr.async_get_diskspace() - elif key == "commands": - self.data[key] = await self.sonarr.async_get_commands() - elif key == "queue": - self.data[key] = await self.sonarr.async_get_queue( - include_series=True, include_episode=True - ) - elif key == "series": - self.data[key] = await self.sonarr.async_get_series() - elif key == "upcoming": - local = dt_util.start_of_local_day().replace(microsecond=0) - start = dt_util.as_utc(local) - end = start + timedelta(days=self.upcoming_days) - - self.data[key] = await self.sonarr.async_get_calendar( - start_date=start, - end_date=end, - include_series=True, - ) - elif key == "wanted": - self.data[key] = await self.sonarr.async_get_wanted( - page_size=self.wanted_max_items, - include_series=True, - ) + coordinator: SonarrDataUpdateCoordinator[SonarrDataT] + entity_description: SonarrSensorEntityDescription[SonarrDataT] @property - def extra_state_attributes(self) -> dict[str, str] | None: + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes of the entity.""" - attrs = {} - key = self.entity_description.key - - if key == "diskspace" and self.data.get(key) is not None: - for disk in self.data[key]: - free = disk.freeSpace / 1024**3 - total = disk.totalSpace / 1024**3 - usage = free / total * 100 - - attrs[ - disk.path - ] = f"{free:.2f}/{total:.2f}{self.unit_of_measurement} ({usage:.2f}%)" - elif key == "commands" and self.data.get(key) is not None: - for command in self.data[key]: - attrs[command.name] = command.status - elif key == "queue" and self.data.get(key) is not None: - for item in self.data[key].records: - remaining = 1 if item.size == 0 else item.sizeleft / item.size - remaining_pct = 100 * (1 - remaining) - identifier = f"S{item.episode.seasonNumber:02d}E{item.episode. episodeNumber:02d}" - - name = f"{item.series.title} {identifier}" - attrs[name] = f"{remaining_pct:.2f}%" - elif key == "series" and self.data.get(key) is not None: - for item in self.data[key]: - stats = item.statistics - attrs[ - item.title - ] = f"{getattr(stats,'episodeFileCount', 0)}/{getattr(stats, 'episodeCount', 0)} Episodes" - elif key == "upcoming" and self.data.get(key) is not None: - for episode in self.data[key]: - identifier = f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}" - attrs[episode.series.title] = identifier - elif key == "wanted" and self.data.get(key) is not None: - for item in self.data[key].records: - identifier = f"S{item.seasonNumber:02d}E{item.episodeNumber:02d}" - - name = f"{item.series.title} {identifier}" - attrs[name] = dt_util.as_local( - item.airDateUtc.replace(tzinfo=dt_util.UTC) - ).isoformat() - - return attrs + return self.entity_description.attributes_fn(self.coordinator.data) @property def native_value(self) -> StateType: """Return the state of the sensor.""" - key = self.entity_description.key - - if key == "diskspace" and self.data.get(key) is not None: - total_free = sum(disk.freeSpace for disk in self.data[key]) - free = total_free / 1024**3 - return f"{free:.2f}" - - if key == "commands" and self.data.get(key) is not None: - return len(self.data[key]) - - if key == "queue" and self.data.get(key) is not None: - return self.data[key].totalRecords - - if key == "series" and self.data.get(key) is not None: - return len(self.data[key]) - - if key == "upcoming" and self.data.get(key) is not None: - return len(self.data[key]) - - if key == "wanted" and self.data.get(key) is not None: - return self.data[key].totalRecords - - return None + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/sonarr/translations/nb.json b/homeassistant/components/sonarr/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/sonarr/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sonarr/translations/no.json b/homeassistant/components/sonarr/translations/no.json index 3d64a9199a41415ab9a30441ad286e6798279028..e51a76b59180dd0968b6e9afb1c4dfd4b55795f6 100644 --- a/homeassistant/components/sonarr/translations/no.json +++ b/homeassistant/components/sonarr/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Tjenesten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "unknown": "Uventet feil" }, "error": { diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index b8506cd278329bf97994fed224a789412ccc3b81..57438d1864a215fe548d7eb4892dbad140b35fac 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.28.0"], + "requirements": ["soco==0.28.1"], "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 1b0d8dc6ed1e6eaad59f5407dcd6a1675cb48160..4195f284ffe832e8c90973244260842bc71b7d67 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -438,7 +438,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): soco.play_uri(uri, title=favorite.title) else: soco.clear_queue() - soco.add_to_queue(favorite.reference) + soco.add_to_queue(favorite.reference, timeout=LONG_SERVICE_TIMEOUT) soco.play_from_queue(0) @property @@ -586,13 +586,15 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_id = async_process_play_media_url(self.hass, media_id) if enqueue == MediaPlayerEnqueue.ADD: - soco.add_uri_to_queue(media_id) + soco.add_uri_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 = soco.add_uri_to_queue(media_id, position=pos) + new_pos = soco.add_uri_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: @@ -609,7 +611,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): _LOGGER.error('Could not find a Sonos playlist named "%s"', media_id) else: soco.clear_queue() - soco.add_to_queue(playlist) + soco.add_to_queue(playlist, timeout=LONG_SERVICE_TIMEOUT) soco.play_from_queue(0) elif media_type in PLAYABLE_MEDIA_TYPES: item = media_browser.get_media(self.media.library, media_id, media_type) diff --git a/homeassistant/components/soundtouch/translations/bg.json b/homeassistant/components/soundtouch/translations/bg.json index ab665e1e59b0043a2e7d10d6948c1e4ca2938fbb..5d235f77133708c0d51e8e00ffad665157580268 100644 --- a/homeassistant/components/soundtouch/translations/bg.json +++ b/homeassistant/components/soundtouch/translations/bg.json @@ -5,6 +5,13 @@ }, "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" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/soundtouch/translations/id.json b/homeassistant/components/soundtouch/translations/id.json index b5114dcb398451a78a88a022b2dd2dbc40da0205..cabbb3a62240ee67a06dd7dc36a62a640c6cb3d2 100644 --- a/homeassistant/components/soundtouch/translations/id.json +++ b/homeassistant/components/soundtouch/translations/id.json @@ -20,8 +20,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Proses konfigurasi Bose SoundTouch lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Bose SoundTouch dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Bose SoundTouch dalam proses penghapusan" + "description": "Proses konfigurasi Integrasi Bose SoundTouch lewat YAML dalam proses penghapusan.\n\nKonfigurasi YAML yang ada telah diimpor ke antarmuka secara otomatis.\n\nHapus konfigurasi YAML Integrasi Bose SoundTouch dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Bose SoundTouch dalam proses penghapusan" } } } \ No newline at end of file diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 17238f7481081f719f76ec58e696964e1d334398..8cf32def197dd61cfd1f635ed982c2f861b854a0 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -8,9 +8,8 @@ import speedtest from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import CoreState, HomeAssistant, ServiceCall +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -20,7 +19,6 @@ from .const import ( DEFAULT_SERVER, DOMAIN, PLATFORMS, - SPEED_TEST_SERVICE, ) _LOGGER = logging.getLogger(__name__) @@ -58,8 +56,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload SpeedTest Entry from config_entry.""" - hass.services.async_remove(DOMAIN, SPEED_TEST_SERVICE) - unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) @@ -141,30 +137,6 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): except speedtest.SpeedtestException as err: raise ConfigEntryNotReady from err - async def request_update(call: ServiceCall) -> None: - """Request update.""" - async_create_issue( - self.hass, - DOMAIN, - "deprecated_service", - breaks_in_ha_version="2022.11.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_service", - ) - - _LOGGER.warning( - ( - 'The "%s" service is deprecated and will be removed in "2022.11.0"; ' - 'use the "homeassistant.update_entity" service and pass it a target Speedtest entity_id' - ), - SPEED_TEST_SERVICE, - ) - await self.async_request_refresh() - - self.hass.services.async_register(DOMAIN, SPEED_TEST_SERVICE, request_update) - self.config_entry.async_on_unload( self.config_entry.add_update_listener(options_updated_listener) ) diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py index e2455ad63df09f4259140b183cbd7a208927f039..e6ced462b4524f53cb6ce8a1336f49e927bf2eb0 100644 --- a/homeassistant/components/speedtestdotnet/const.py +++ b/homeassistant/components/speedtestdotnet/const.py @@ -14,8 +14,6 @@ from homeassistant.const import ( DOMAIN: Final = "speedtestdotnet" -SPEED_TEST_SERVICE: Final = "speedtest" - @dataclass class SpeedtestSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/speedtestdotnet/services.yaml b/homeassistant/components/speedtestdotnet/services.yaml deleted file mode 100644 index fdc6be746f865e2a85c9fa236972b1d90089460a..0000000000000000000000000000000000000000 --- a/homeassistant/components/speedtestdotnet/services.yaml +++ /dev/null @@ -1,3 +0,0 @@ -speedtest: - name: Speedtest - description: Immediately execute a speed test with Speedtest.net diff --git a/homeassistant/components/speedtestdotnet/strings.json b/homeassistant/components/speedtestdotnet/strings.json index d4117d82cf3821c1f79e4a42694ce438676f35f0..c4dad30cb096a1955805c53b3a1ac3ecc404538e 100644 --- a/homeassistant/components/speedtestdotnet/strings.json +++ b/homeassistant/components/speedtestdotnet/strings.json @@ -19,18 +19,5 @@ } } } - }, - "issues": { - "deprecated_service": { - "title": "The speedtest service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The speedtest service is being removed", - "description": "Update any automations or scripts that use this service to instead use the `homeassistant.update_entity` service with a target Speedtest entity_id. Then, click SUBMIT below to mark this issue as resolved." - } - } - } - } } } diff --git a/homeassistant/components/spider/translations/nb.json b/homeassistant/components/spider/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/spider/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 2940700d2301d733997f6c88c128bef937b96be8..0ce71f371df7ab4633867a5e6435faae47bbe594 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,5 +9,6 @@ "config_flow": true, "quality_scale": "silver", "iot_class": "cloud_polling", + "integration_type": "service", "loggers": ["spotipy"] } diff --git a/homeassistant/components/spotify/translations/bg.json b/homeassistant/components/spotify/translations/bg.json index 35e3428d677cb18425e40e1b875960b388582614..b9da3fe07e8c2e4685eb9145f1ce18c4a629a65a 100644 --- a/homeassistant/components/spotify/translations/bg.json +++ b/homeassistant/components/spotify/translations/bg.json @@ -8,6 +8,7 @@ }, "issues": { "removed_yaml": { + "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435\u0442\u043e \u043d\u0430 Spotify \u0441 \u043f\u043e\u043c\u043e\u0449\u0442\u0430 \u043d\u0430 YAML \u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0430\u0442\u043e.\n\n\u0412\u0430\u0448\u0430\u0442\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430 YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0435 \u0441\u0435 \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 \u043e\u0442 Home Assistant.\n\n\u041f\u0440\u0435\u043c\u0430\u0445\u043d\u0435\u0442\u0435 YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043e\u0442 \u0432\u0430\u0448\u0438\u044f \u0444\u0430\u0439\u043b configuration.yaml \u0438 \u0440\u0435\u0441\u0442\u0430\u0440\u0442\u0438\u0440\u0430\u0439\u0442\u0435 Home Assistant, \u0437\u0430 \u0434\u0430 \u043a\u043e\u0440\u0438\u0433\u0438\u0440\u0430\u0442\u0435 \u0442\u043e\u0437\u0438 \u043f\u0440\u043e\u0431\u043b\u0435\u043c.", "title": "YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 Spotify \u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0430\u0442\u0430" } } diff --git a/homeassistant/components/spotify/translations/id.json b/homeassistant/components/spotify/translations/id.json index ef201bc638f69310c5b5e61e961836daf04facb2..dda2a6ab6f980c3f302a48b94d22d9c988dd9794 100644 --- a/homeassistant/components/spotify/translations/id.json +++ b/homeassistant/components/spotify/translations/id.json @@ -21,8 +21,8 @@ }, "issues": { "removed_yaml": { - "description": "Proses konfigurasi Spotify lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Spotify telah dihapus" + "description": "Proses konfigurasi Integrasi Spotify lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Spotify telah dihapus" } }, "system_health": { diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index cb1556b31545fa615a082a0119cb25dd714746f5..7484ca0feb7fb4dc59fd0e4ac0997eff67946fcb 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.41"], + "requirements": ["sqlalchemy==1.4.42"], "codeowners": ["@dgomes", "@gjohansson-ST"], "config_flow": true, "iot_class": "local_polling" diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 979b4c36a98095c35dd5a046a26a136f4ab85b7a..c66bc8af9a55eaa33096080e9c298d2fa44f68bc 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -156,7 +156,6 @@ async def library_payload(hass, player): media_content_type=item, can_play=True, can_expand=True, - thumbnail="https://brands.home-assistant.io/_/squeezebox/logo.png", ) ) diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 018333d420bc278a9e7eb5888a1ee25f710d0235..2c1692b6085627c20f51f258ba25b1581fbfc324 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -3,7 +3,7 @@ "name": "Squeezebox (Logitech Media Server)", "documentation": "https://www.home-assistant.io/integrations/squeezebox", "codeowners": ["@rajlaud"], - "requirements": ["pysqueezebox==0.6.0"], + "requirements": ["pysqueezebox==0.6.1"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/squeezebox/translations/nb.json b/homeassistant/components/squeezebox/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/squeezebox/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 0e6c7cda577d6856e65cc248ba5a32683008be6e..61b9938e4743ffd4e3ed41ea3d8927b062e033df 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, ENERGY_KILO_WATT_HOUR +from homeassistant.const import ENERGY_KILO_WATT_HOUR from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -83,6 +83,7 @@ async def async_setup_entry( class SrpEntity(SensorEntity): """Implementation of a Srp Energy Usage sensor.""" + _attr_attribution = ATTRIBUTION _attr_should_poll = False def __init__(self, coordinator): @@ -127,17 +128,6 @@ class SrpEntity(SensorEntity): return f"{self.coordinator.data:.2f}" return None - @property - def extra_state_attributes(self): - """Return the state attributes.""" - if not self.coordinator.data: - return None - attributes = { - ATTR_ATTRIBUTION: ATTRIBUTION, - } - - return attributes - @property def available(self): """Return if entity is available.""" diff --git a/homeassistant/components/srp_energy/translations/nb.json b/homeassistant/components/srp_energy/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/srp_energy/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index d221cb162f4bce80f586592b84c40abc0529abcb..d081ef877dee3673015d8f995902b188293fcb07 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -8,27 +8,60 @@ from datetime import timedelta from enum import Enum from ipaddress import IPv4Address, IPv6Address import logging +import socket +from time import time from typing import Any +from urllib.parse import urljoin +import xml.etree.ElementTree as ET from async_upnp_client.aiohttp import AiohttpSessionRequester -from async_upnp_client.const import AddressTupleVXType, DeviceOrServiceType, SsdpSource +from async_upnp_client.const import ( + AddressTupleVXType, + DeviceIcon, + DeviceInfo, + DeviceOrServiceType, + SsdpSource, +) from async_upnp_client.description_cache import DescriptionCache -from async_upnp_client.ssdp import SSDP_PORT, determine_source_target, is_ipv4_address +from async_upnp_client.server import ( + SSDP_SEARCH_RESPONDER_OPTION_ALWAYS_REPLY_WITH_ROOT_DEVICE, + SSDP_SEARCH_RESPONDER_OPTIONS, + UpnpServer, + UpnpServerDevice, + UpnpServerService, +) +from async_upnp_client.ssdp import ( + SSDP_PORT, + determine_source_target, + fix_ipv6_address_scope_id, + is_ipv4_address, +) from async_upnp_client.ssdp_listener import SsdpDevice, SsdpDeviceTracker, SsdpListener from async_upnp_client.utils import CaseInsensitiveDict from homeassistant import config_entries from homeassistant.components import network -from homeassistant.const import EVENT_HOMEASSISTANT_STOP, MATCH_ALL +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, + MATCH_ALL, + __version__ as current_version, +) from homeassistant.core import HomeAssistant, callback as core_callback from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.instance_id import async_get as async_get_instance_id +from homeassistant.helpers.network import get_url +from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_ssdp, bind_hass DOMAIN = "ssdp" +SSDP_SCANNER = "scanner" +UPNP_SERVER = "server" +UPNP_SERVER_MIN_PORT = 40000 +UPNP_SERVER_MAX_PORT = 40100 SCAN_INTERVAL = timedelta(minutes=2) IPV4_BROADCAST = IPv4Address("255.255.255.255") @@ -133,7 +166,7 @@ async def async_register_callback( Returns a callback that can be used to cancel the registration. """ - scanner: Scanner = hass.data[DOMAIN] + scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_register_callback(callback, match_dict) @@ -142,7 +175,7 @@ async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name hass: HomeAssistant, udn: str, st: str ) -> SsdpServiceInfo | None: """Fetch the discovery info cache.""" - scanner: Scanner = hass.data[DOMAIN] + scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_udn_st(udn, st) @@ -151,7 +184,7 @@ async def async_get_discovery_info_by_st( # pylint: disable=invalid-name hass: HomeAssistant, st: str ) -> list[SsdpServiceInfo]: """Fetch all the entries matching the st.""" - scanner: Scanner = hass.data[DOMAIN] + scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_st(st) @@ -160,19 +193,34 @@ async def async_get_discovery_info_by_udn( hass: HomeAssistant, udn: str ) -> list[SsdpServiceInfo]: """Fetch all the entries matching the udn.""" - scanner: Scanner = hass.data[DOMAIN] + scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_udn(udn) +async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6Address]: + """Build the list of ssdp sources.""" + return { + source_ip + for source_ip in await network.async_get_enabled_source_ips(hass) + if not source_ip.is_loopback and not source_ip.is_global + } + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the SSDP integration.""" integration_matchers = IntegrationMatchers() integration_matchers.async_setup(await async_get_ssdp(hass)) - scanner = hass.data[DOMAIN] = Scanner(hass, integration_matchers) + scanner = Scanner(hass, integration_matchers) + server = Server(hass) + hass.data[DOMAIN] = { + SSDP_SCANNER: scanner, + UPNP_SERVER: server, + } - asyncio.create_task(scanner.async_start()) + await scanner.async_start() + await server.async_start() return True @@ -322,14 +370,6 @@ class Scanner: return_exceptions=True, ) - async def _async_build_source_set(self) -> set[IPv4Address | IPv6Address]: - """Build the list of ssdp sources.""" - return { - source_ip - for source_ip in await network.async_get_enabled_source_ips(self.hass) - if not source_ip.is_loopback and not source_ip.is_global - } - async def async_scan(self, *_: Any) -> None: """Scan for new entries using ssdp listeners.""" await self.async_scan_multicast() @@ -369,7 +409,7 @@ class Scanner: """Start the SSDP Listeners.""" # Devices are shared between all sources. device_tracker = SsdpDeviceTracker() - for source_ip in await self._async_build_source_set(): + for source_ip in await async_build_source_set(self.hass): source_ip_str = str(source_ip) if source_ip.version == 6: source_tuple: AddressTupleVXType = ( @@ -381,6 +421,7 @@ class Scanner: else: source_tuple = (source_ip_str, 0) source, target = determine_source_target(source_tuple) + source = fix_ipv6_address_scope_id(source) or source self._ssdp_listeners.append( SsdpListener( async_callback=self._ssdp_listener_callback, @@ -559,3 +600,174 @@ def _udn_from_usn(usn: str | None) -> str | None: if usn.startswith("uuid:"): return usn.split("::")[0] return None + + +class HassUpnpServiceDevice(UpnpServerDevice): + """Hass Device.""" + + DEVICE_DEFINITION = DeviceInfo( + device_type="urn:home-assistant.io:device:HomeAssistant:1", + friendly_name="filled_later_on", + manufacturer="Home Assistant", + manufacturer_url="https://www.home-assistant.io", + model_description=None, + model_name="filled_later_on", + model_number=current_version, + model_url="https://www.home-assistant.io", + serial_number="filled_later_on", + udn="filled_later_on", + upc=None, + presentation_url="https://my.home-assistant.io/", + url="/device.xml", + icons=[ + DeviceIcon( + mimetype="image/png", + width=1024, + height=1024, + depth=24, + url="/static/icons/favicon-1024x1024.png", + ), + DeviceIcon( + mimetype="image/png", + width=512, + height=512, + depth=24, + url="/static/icons/favicon-512x512.png", + ), + DeviceIcon( + mimetype="image/png", + width=384, + height=384, + depth=24, + url="/static/icons/favicon-384x384.png", + ), + DeviceIcon( + mimetype="image/png", + width=192, + height=192, + depth=24, + url="/static/icons/favicon-192x192.png", + ), + ], + xml=ET.Element("server_device"), + ) + EMBEDDED_DEVICES: list[type[UpnpServerDevice]] = [] + SERVICES: list[type[UpnpServerService]] = [] + + +async def _async_find_next_available_port(source: AddressTupleVXType) -> int: + """Get a free TCP port.""" + family = socket.AF_INET if is_ipv4_address(source) else socket.AF_INET6 + test_socket = socket.socket(family, socket.SOCK_STREAM) + test_socket.setblocking(False) + test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + for port in range(UPNP_SERVER_MIN_PORT, UPNP_SERVER_MAX_PORT): + try: + test_socket.bind(source) + return port + except OSError: + if port == UPNP_SERVER_MAX_PORT: + raise + + raise RuntimeError("unreachable") + + +class Server: + """Class to be visible via SSDP searching and advertisements.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize class.""" + self.hass = hass + self._upnp_servers: list[UpnpServer] = [] + + async def async_start(self) -> None: + """Start the server.""" + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) + await self._async_start_upnp_servers() + + async def _async_get_instance_udn(self) -> str: + """Get Unique Device Name for this instance.""" + instance_id = await async_get_instance_id(self.hass) + return f"uuid:{instance_id[0:8]}-{instance_id[8:12]}-{instance_id[12:16]}-{instance_id[16:20]}-{instance_id[20:32]}".upper() + + async def _async_start_upnp_servers(self) -> None: + """Start the UPnP/SSDP servers.""" + # Update UDN with our instance UDN. + udn = await self._async_get_instance_udn() + system_info = await async_get_system_info(self.hass) + model_name = system_info["installation_type"] + presentation_url = get_url(self.hass, allow_ip=True, prefer_external=False) + serial_number = await async_get_instance_id(self.hass) + HassUpnpServiceDevice.DEVICE_DEFINITION = ( + HassUpnpServiceDevice.DEVICE_DEFINITION._replace( + udn=udn, + friendly_name=f"{self.hass.config.location_name} (Home Assistant)", + model_name=model_name, + presentation_url=presentation_url, + serial_number=serial_number, + ) + ) + + # Update icon URLs. + for index, icon in enumerate(HassUpnpServiceDevice.DEVICE_DEFINITION.icons): + new_url = urljoin(presentation_url, icon.url) + HassUpnpServiceDevice.DEVICE_DEFINITION.icons[index] = icon._replace( + url=new_url + ) + + # Start a server on all source IPs. + boot_id = int(time()) + for source_ip in await async_build_source_set(self.hass): + source_ip_str = str(source_ip) + if source_ip.version == 6: + source_tuple: AddressTupleVXType = ( + source_ip_str, + 0, + 0, + int(getattr(source_ip, "scope_id")), + ) + else: + source_tuple = (source_ip_str, 0) + source, target = determine_source_target(source_tuple) + source = fix_ipv6_address_scope_id(source) or source + http_port = await _async_find_next_available_port(source) + _LOGGER.debug("Binding UPnP HTTP server to: %s:%s", source_ip, http_port) + self._upnp_servers.append( + UpnpServer( + source=source, + target=target, + http_port=http_port, + server_device=HassUpnpServiceDevice, + boot_id=boot_id, + options={ + SSDP_SEARCH_RESPONDER_OPTIONS: { + SSDP_SEARCH_RESPONDER_OPTION_ALWAYS_REPLY_WITH_ROOT_DEVICE: True + } + }, + ) + ) + results = await asyncio.gather( + *(upnp_server.async_start() for upnp_server in self._upnp_servers), + return_exceptions=True, + ) + failed_servers = [] + for idx, result in enumerate(results): + if isinstance(result, Exception): + _LOGGER.debug( + "Failed to setup server for %s: %s", + self._upnp_servers[idx].source, + result, + ) + failed_servers.append(self._upnp_servers[idx]) + for server in failed_servers: + self._upnp_servers.remove(server) + + async def async_stop(self, *_: Any) -> None: + """Stop the server.""" + await self._async_stop_upnp_servers() + + async def _async_stop_upnp_servers(self) -> None: + """Stop UPnP/SSDP servers.""" + for server in self._upnp_servers: + await server.async_stop() diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index e403867226adfa1147b5f5f5bb87782229e6d926..59d9d6ddad81fd7c75cb0c251095bc63438f22ff 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.2"], + "requirements": ["async-upnp-client==0.32.1"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 3f33fa015b93721b810a88a39c26927ec98c197e..cfc093c77629cb9552362be573568b6521b2eae3 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -25,6 +25,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, + PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -33,10 +34,11 @@ from homeassistant.core import ( Event, HomeAssistant, State, + async_get_hass, callback, split_entity_id, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_track_point_in_utc_time, @@ -78,20 +80,14 @@ STAT_MEDIAN = "median" STAT_NOISINESS = "noisiness" STAT_QUANTILES = "quantiles" STAT_STANDARD_DEVIATION = "standard_deviation" +STAT_SUM = "sum" +STAT_SUM_DIFFERENCES = "sum_differences" +STAT_SUM_DIFFERENCES_NONNEGATIVE = "sum_differences_nonnegative" STAT_TOTAL = "total" STAT_VALUE_MAX = "value_max" STAT_VALUE_MIN = "value_min" STAT_VARIANCE = "variance" -DEPRECATION_WARNING_CHARACTERISTIC = ( - "The configuration parameter 'state_characteristic' will become " - "mandatory in a future release of the statistics integration. " - "Please add 'state_characteristic: %s' to the configuration of " - "sensor '%s' to keep the current behavior. Read the documentation " - "for further details: " - "https://www.home-assistant.io/integrations/statistics/" -) - # Statistics supported by a sensor source (numeric) STATS_NUMERIC_SUPPORT = { STAT_AVERAGE_LINEAR, @@ -113,6 +109,9 @@ STATS_NUMERIC_SUPPORT = { STAT_NOISINESS, STAT_QUANTILES, STAT_STANDARD_DEVIATION, + STAT_SUM, + STAT_SUM_DIFFERENCES, + STAT_SUM_DIFFERENCES_NONNEGATIVE, STAT_TOTAL, STAT_VALUE_MAX, STAT_VALUE_MIN, @@ -159,6 +158,9 @@ STAT_NUMERIC_RETAIN_UNIT = { STAT_MEDIAN, STAT_NOISINESS, STAT_STANDARD_DEVIATION, + STAT_SUM, + STAT_SUM_DIFFERENCES, + STAT_SUM_DIFFERENCES_NONNEGATIVE, STAT_TOTAL, STAT_VALUE_MAX, STAT_VALUE_MIN, @@ -179,7 +181,6 @@ CONF_QUANTILE_INTERVALS = "quantile_intervals" CONF_QUANTILE_METHOD = "quantile_method" DEFAULT_NAME = "Stats" -DEFAULT_BUFFER_SIZE = 20 DEFAULT_PRECISION = 2 DEFAULT_QUANTILE_INTERVALS = 4 DEFAULT_QUANTILE_METHOD = "exclusive" @@ -192,10 +193,19 @@ def valid_state_characteristic_configuration(config: dict[str, Any]) -> dict[str if config.get(CONF_STATE_CHARACTERISTIC) is None: config[CONF_STATE_CHARACTERISTIC] = STAT_COUNT if is_binary else STAT_MEAN - _LOGGER.warning( - DEPRECATION_WARNING_CHARACTERISTIC, - config[CONF_STATE_CHARACTERISTIC], - config[CONF_NAME], + issue_registry.async_create_issue( + hass=async_get_hass(), + domain=DOMAIN, + issue_id=f"{config[CONF_ENTITY_ID]}_default_characteristic", + breaks_in_ha_version="2022.12.0", + is_fixable=False, + severity=issue_registry.IssueSeverity.WARNING, + translation_key="deprecation_warning_characteristic", + translation_placeholders={ + "entity": config[CONF_NAME], + "characteristic": config[CONF_STATE_CHARACTERISTIC], + }, + learn_more_url="https://github.com/home-assistant/core/pull/60402", ) characteristic = cast(str, config[CONF_STATE_CHARACTERISTIC]) @@ -210,15 +220,32 @@ def valid_state_characteristic_configuration(config: dict[str, Any]) -> dict[str return config +def valid_boundary_configuration(config: dict[str, Any]) -> dict[str, Any]: + """Validate that sampling_size, max_age, or both are provided.""" + + if config.get(CONF_SAMPLES_MAX_BUFFER_SIZE) is None: + config[CONF_SAMPLES_MAX_BUFFER_SIZE] = 20 + issue_registry.async_create_issue( + hass=async_get_hass(), + domain=DOMAIN, + issue_id=f"{config[CONF_ENTITY_ID]}_invalid_boundary_config", + breaks_in_ha_version="2022.12.0", + is_fixable=False, + severity=issue_registry.IssueSeverity.WARNING, + translation_key="deprecation_warning_size", + translation_placeholders={"entity": config[CONF_NAME]}, + learn_more_url="https://github.com/home-assistant/core/pull/69700", + ) + return config + + _PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_STATE_CHARACTERISTIC): cv.string, - vol.Optional( - CONF_SAMPLES_MAX_BUFFER_SIZE, default=DEFAULT_BUFFER_SIZE - ): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_SAMPLES_MAX_BUFFER_SIZE): vol.Coerce(int), vol.Optional(CONF_MAX_AGE): cv.time_period, vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), vol.Optional( @@ -232,6 +259,7 @@ _PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = vol.All( _PLATFORM_SCHEMA_BASE, valid_state_characteristic_configuration, + valid_boundary_configuration, ) @@ -377,7 +405,7 @@ class StatisticsSensor(SensorEntity): base_unit: str | None = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) unit: str | None if self.is_binary and self._state_characteristic in STAT_BINARY_PERCENTAGE: - unit = "%" + unit = PERCENTAGE elif not base_unit: unit = None elif self._state_characteristic in STAT_NUMERIC_RETAIN_UNIT: @@ -669,10 +697,7 @@ class StatisticsSensor(SensorEntity): def _stat_noisiness(self) -> StateType: if len(self.states) >= 2: - diff_sum = sum( - abs(j - i) for i, j in zip(list(self.states), list(self.states)[1:]) - ) - return diff_sum / (len(self.states) - 1) + return cast(float, self._stat_sum_differences()) / (len(self.states) - 1) return None def _stat_quantiles(self) -> StateType: @@ -694,11 +719,31 @@ class StatisticsSensor(SensorEntity): return statistics.stdev(self.states) return None - def _stat_total(self) -> StateType: + def _stat_sum(self) -> StateType: if len(self.states) > 0: return sum(self.states) return None + def _stat_sum_differences(self) -> StateType: + if len(self.states) >= 2: + diff_sum = sum( + abs(j - i) for i, j in zip(list(self.states), list(self.states)[1:]) + ) + return diff_sum + return None + + def _stat_sum_differences_nonnegative(self) -> StateType: + if len(self.states) >= 2: + diff_sum_nn = sum( + (j - i if j >= i else j - 0) + for i, j in zip(list(self.states), list(self.states)[1:]) + ) + return diff_sum_nn + return None + + def _stat_total(self) -> StateType: + return self._stat_sum() + def _stat_value_max(self) -> StateType: if len(self.states) > 0: return max(self.states) diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..0cca71f172f7fe1439a23eddc9b4270266170f0f --- /dev/null +++ b/homeassistant/components/statistics/strings.json @@ -0,0 +1,12 @@ +{ + "issues": { + "deprecation_warning_characteristic": { + "description": "The configuration parameter `state_characteristic` of the statistics integration will become mandatory.\n\nPlease add `state_characteristic: {characteristic}` to the configuration of sensor `{entity}` to keep the current behavior.\n\nRead the documentation of the statistics integration for further details: https://www.home-assistant.io/integrations/statistics/", + "title": "Mandatory 'state_characteristic' assumed for a Statistics entity" + }, + "deprecation_warning_size": { + "description": "The configuration parameter `sampling_size` of the statistics integration defaulted to the value 20 so far, which will change.\n\nPlease check the configuration for sensor `{entity}` and add suited boundaries, e.g., `sampling_size: 20` to keep the current behavior. The configuration of the statistics integration will become more flexible with version 2022.12.0 and accept either `sampling_size` or `max_age`, or both settings. The request above prepares your configuration for this otherwise breaking change.\n\nRead the documentation of the statistics integration for further details: https://www.home-assistant.io/integrations/statistics/", + "title": "Implicit 'sampling_size' assumed for a Statistics entity" + } + } +} diff --git a/homeassistant/components/statistics/translations/ca.json b/homeassistant/components/statistics/translations/ca.json new file mode 100644 index 0000000000000000000000000000000000000000..55f2b128d8b698df8d2698289bf2e485153dc809 --- /dev/null +++ b/homeassistant/components/statistics/translations/ca.json @@ -0,0 +1,12 @@ +{ + "issues": { + "deprecation_warning_characteristic": { + "description": "El par\u00e0metre de configuraci\u00f3 `state_characteristic` de la integraci\u00f3 d'estad\u00edstiques ser\u00e0 obligatori. \n\nAfegeix `state_characteristic: {characteristic}` a la configuraci\u00f3 del sensor `{entity}` per mantenir el comportament actual. \n\nLlegeix la documentaci\u00f3 de la integraci\u00f3 d'estad\u00edstiques per a m\u00e9s informaci\u00f3: https://www.home-assistant.io/integrations/statistics/", + "title": "S'ha assumit 'state_characteristic', variable obligat\u00f2ria per a una entitat d'estad\u00edstica" + }, + "deprecation_warning_size": { + "description": "El par\u00e0metre de configuraci\u00f3 `sampling_size` de la integraci\u00f3 d'estad\u00edstiques tenia un valor predeterminat fix de 20, ara aix\u00f2 canviar\u00e0. \n\nSi us plau, comprova la configuraci\u00f3 del sensor `{entity}` i afegeix els valors l\u00edmits adequats, per exemple `sampling_size: 20` per mantenir el comportament actual. La configuraci\u00f3 de la integraci\u00f3 d'estad\u00edstiques ser\u00e0 m\u00e9s flexible a partir de la versi\u00f3 2022.12.0 i acceptar\u00e0 `sampling_size` i/o `max_age`. Aquesta nota et prepara per a aquest canvi de la configuraci\u00f3 que podria portar problemes en les teves entitats estad\u00edstiques. \n\nLlegeix la documentaci\u00f3 de la integraci\u00f3 d'estad\u00edstiques per a m\u00e9s informaci\u00f3: https://www.home-assistant.io/integrations/statistics/", + "title": "S'ha assumit 'sampling_size', per a una entitat d'estad\u00edstica" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/statistics/translations/de.json b/homeassistant/components/statistics/translations/de.json new file mode 100644 index 0000000000000000000000000000000000000000..f29b161ec398434852080aebd45a2048659551cb --- /dev/null +++ b/homeassistant/components/statistics/translations/de.json @@ -0,0 +1,12 @@ +{ + "issues": { + "deprecation_warning_characteristic": { + "description": "Der Konfigurationsparameter `state_characteristic` der Statistik-Integration wird zur Pflicht.\n\nBitte f\u00fcge `state_characteristic: {characteristic}` in die Konfiguration des Sensors `{entity}` ein, um das aktuelle Verhalten beizubehalten.\n\nLies die Dokumentation der Statistik-Integration f\u00fcr weitere Details: https://www.home-assistant.io/integrations/statistics/", + "title": "Obligatorisches 'state_characteristic' wird f\u00fcr eine Statistikentit\u00e4t angenommen" + }, + "deprecation_warning_size": { + "description": "Der Konfigurationsparameter `sampling_size` der Statistikintegration war bisher standardm\u00e4\u00dfig auf den Wert 20 eingestellt, was sich \u00e4ndern wird.\n\nBitte \u00fcberpr\u00fcfe die Konfiguration f\u00fcr Sensor `{entity}` und f\u00fcge geeignete Grenzen hinzu, zB `sampling_size: 20`, um das aktuelle Verhalten beizubehalten. Die Konfiguration der Statistikintegration wird mit Version 2022.12.0 flexibler und akzeptiert entweder \u201esampling_size\u201c oder \u201emax_age\u201c oder beide Einstellungen. Die obige Anfrage bereitet deine Konfiguration auf diese ansonsten bahnbrechende \u00c4nderung vor. \n\nLies die Dokumentation der Statistikintegration f\u00fcr weitere Details: https://www.home-assistant.io/integrations/statistics/", + "title": "Implizite 'sampling_size' angenommen f\u00fcr eine Statistikentit\u00e4t" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/statistics/translations/en.json b/homeassistant/components/statistics/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..730f5a5656d5d599f3e8af964a0570527fbfe4f4 --- /dev/null +++ b/homeassistant/components/statistics/translations/en.json @@ -0,0 +1,12 @@ +{ + "issues": { + "deprecation_warning_characteristic": { + "description": "The configuration parameter `state_characteristic` of the statistics integration will become mandatory.\n\nPlease add `state_characteristic: {characteristic}` to the configuration of sensor `{entity}` to keep the current behavior.\n\nRead the documentation of the statistics integration for further details: https://www.home-assistant.io/integrations/statistics/", + "title": "Mandatory 'state_characteristic' assumed for a Statistics entity" + }, + "deprecation_warning_size": { + "description": "The configuration parameter `sampling_size` of the statistics integration defaulted to the value 20 so far, which will change.\n\nPlease check the configuration for sensor `{entity}` and add suited boundaries, e.g., `sampling_size: 20` to keep the current behavior. The configuration of the statistics integration will become more flexible with version 2022.12.0 and accept either `sampling_size` or `max_age`, or both settings. The request above prepares your configuration for this otherwise breaking change.\n\nRead the documentation of the statistics integration for further details: https://www.home-assistant.io/integrations/statistics/", + "title": "Implicit 'sampling_size' assumed for a Statistics entity" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/statistics/translations/pl.json b/homeassistant/components/statistics/translations/pl.json new file mode 100644 index 0000000000000000000000000000000000000000..f10ea9d2bbebcfcd7dced42337e790a78546e3dd --- /dev/null +++ b/homeassistant/components/statistics/translations/pl.json @@ -0,0 +1,12 @@ +{ + "issues": { + "deprecation_warning_characteristic": { + "description": "Parametr konfiguracyjny `state_characteristic` integracji Statystyk stanie si\u0119 obowi\u0105zkowy. \n\nProsz\u0119 doda\u0107 `state_characteristic: \"{characteristic}\" do konfiguracji sensora \"{entity}\", aby zachowa\u0107 bie\u017c\u0105ce zachowanie. \n\nPrzeczytaj dokumentacj\u0119 integracji Statystyk, aby uzyska\u0107 wi\u0119cej informacji: https://www.home-assistant.io/integrations/statistics/", + "title": "Obowi\u0105zkowe \"state_characteristic\" przyj\u0119ty dla encji Statystyk" + }, + "deprecation_warning_size": { + "description": "Parametr konfiguracyjny `sampling_size` integracji Statystyk do tej pory mia\u0142 domy\u015bln\u0105 warto\u015b\u0107 20, kt\u00f3ra teraz si\u0119 zmieni. \n\nSprawd\u017a konfiguracj\u0119 sensora \"{entity}\" i dodaj odpowiednie granice, np. \"sampling_size: 20\", aby zachowa\u0107 bie\u017c\u0105ce zachowanie. Konfiguracja integracji Statystyk stanie si\u0119 bardziej elastyczna w wersji 2022.12.0 i zaakceptuje albo \"sampling_size\" lub \"max_age\" lub oba te ustawienia. Powy\u017csze ostrze\u017cenie przygotowuje twoj\u0105 konfiguracj\u0119 na t\u0119, w przeciwnym razie, prze\u0142omow\u0105 zmian\u0119. \n\nPrzeczytaj dokumentacj\u0119 integracji Statystyk, aby uzyska\u0107 wi\u0119cej informacji: https://www.home-assistant.io/integrations/statistics/", + "title": "Niejawny 'sampling_size' przyj\u0119ty dla encji Statystyk" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/statistics/translations/pt-BR.json b/homeassistant/components/statistics/translations/pt-BR.json new file mode 100644 index 0000000000000000000000000000000000000000..1c95540dadb7e9c45ba1e4668e33ea2b56d3b692 --- /dev/null +++ b/homeassistant/components/statistics/translations/pt-BR.json @@ -0,0 +1,12 @@ +{ + "issues": { + "deprecation_warning_characteristic": { + "description": "O par\u00e2metro de configura\u00e7\u00e3o `state_characteristic` da integra\u00e7\u00e3o de estat\u00edsticas se tornar\u00e1 obrigat\u00f3rio. \n\n Por favor, adicione `state_characteristic: {characteristic} ` \u00e0 configura\u00e7\u00e3o do sensor ` {entity} ` para manter o comportamento atual. \n\n Leia a documenta\u00e7\u00e3o da integra\u00e7\u00e3o de estat\u00edsticas para mais detalhes: https://www.home-assistant.io/integrations/statistics/", + "title": "Obrigat\u00f3rio 'state_characteristic' assumido para uma entidade de Estat\u00edstica" + }, + "deprecation_warning_size": { + "description": "O par\u00e2metro de configura\u00e7\u00e3o `sampling_size` da integra\u00e7\u00e3o de estat\u00edsticas padronizou para o valor 20 at\u00e9 agora, que ser\u00e1 alterado. \n\n Verifique a configura\u00e7\u00e3o do sensor `{entity}` e adicione limites adequados, por exemplo, `sampling_size: 20` para manter o comportamento atual. A configura\u00e7\u00e3o da integra\u00e7\u00e3o de estat\u00edsticas se tornar\u00e1 mais flex\u00edvel com a vers\u00e3o 2022.12.0 e aceitar\u00e1 `sampling_size` ou `max_age`, ou ambas as configura\u00e7\u00f5es. A solicita\u00e7\u00e3o acima prepara sua configura\u00e7\u00e3o para essa altera\u00e7\u00e3o que, de outra forma, seria importante. \n\n Leia a documenta\u00e7\u00e3o da integra\u00e7\u00e3o de estat\u00edsticas para mais detalhes: https://www.home-assistant.io/integrations/statistics/", + "title": "'sampling_size' impl\u00edcito assumido para uma entidade Estat\u00edstica" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/steam_online/translations/bg.json b/homeassistant/components/steam_online/translations/bg.json index 66ae6cc081fe16e3ed3df70060a8719132bee212..8d946452ca01e1f5146710dd145a75477aebd1af 100644 --- a/homeassistant/components/steam_online/translations/bg.json +++ b/homeassistant/components/steam_online/translations/bg.json @@ -19,5 +19,11 @@ } } } + }, + "issues": { + "removed_yaml": { + "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435\u0442\u043e \u043d\u0430 Steam \u0441 \u043f\u043e\u043c\u043e\u0449\u0442\u0430 \u043d\u0430 YAML \u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0430\u0442\u043e.\n\n\u0412\u0430\u0448\u0430\u0442\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430 YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0435 \u0441\u0435 \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 \u043e\u0442 Home Assistant.\n\n\u041f\u0440\u0435\u043c\u0430\u0445\u043d\u0435\u0442\u0435 YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043e\u0442 \u0432\u0430\u0448\u0438\u044f \u0444\u0430\u0439\u043b configuration.yaml \u0438 \u0440\u0435\u0441\u0442\u0430\u0440\u0442\u0438\u0440\u0430\u0439\u0442\u0435 Home Assistant, \u0437\u0430 \u0434\u0430 \u043a\u043e\u0440\u0438\u0433\u0438\u0440\u0430\u0442\u0435 \u0442\u043e\u0437\u0438 \u043f\u0440\u043e\u0431\u043b\u0435\u043c.", + "title": "YAML \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 Steam \u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0430\u0442\u0430" + } } } \ No newline at end of file diff --git a/homeassistant/components/steam_online/translations/id.json b/homeassistant/components/steam_online/translations/id.json index 07130a2a7da98c97370671b5264378a929fc2b62..ebbe21e8e708fab019e52b8883fec417fca7a06b 100644 --- a/homeassistant/components/steam_online/translations/id.json +++ b/homeassistant/components/steam_online/translations/id.json @@ -26,8 +26,8 @@ }, "issues": { "removed_yaml": { - "description": "Proses konfigurasi Steam lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Steam telah dihapus" + "description": "Proses konfigurasi Integrasi Steam lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Steam telah dihapus" } }, "options": { diff --git a/homeassistant/components/steam_online/translations/nb.json b/homeassistant/components/steam_online/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/steam_online/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/steam_online/translations/no.json b/homeassistant/components/steam_online/translations/no.json index 08defe9e2be5e708b4499638d1b38d8db2ee605d..d321a4c0843142dca75dbac8c522e0e75fcf0a8b 100644 --- a/homeassistant/components/steam_online/translations/no.json +++ b/homeassistant/components/steam_online/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Tjenesten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/steamist/translations/nb.json b/homeassistant/components/steamist/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/steamist/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/stookalert/manifest.json b/homeassistant/components/stookalert/manifest.json index 401ed5a27e50ffbcfbb23872211a67b55f2198d2..cd76a52992e1356f4f727d67d88e427062957f8c 100644 --- a/homeassistant/components/stookalert/manifest.json +++ b/homeassistant/components/stookalert/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/stookalert", "codeowners": ["@fwestenberg", "@frenck"], "requirements": ["stookalert==0.1.4"], + "integration_type": "service", "iot_class": "cloud_polling" } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 262c20e420a46e9b53a09de9ee741deab2c3a112..1f79da20542544c6f41ae927d128ffe6ec863e9b 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -2,9 +2,10 @@ "domain": "stream", "name": "Stream", "documentation": "https://www.home-assistant.io/integrations/stream", - "requirements": ["PyTurboJPEG==1.6.7", "ha-av==10.0.0b5"], + "requirements": ["PyTurboJPEG==1.6.7", "ha-av==10.0.0"], "dependencies": ["http"], "codeowners": ["@hunterjm", "@uvjustin", "@allenporter"], "quality_scale": "internal", - "iot_class": "local_push" + "iot_class": "local_push", + "integration_type": "system" } diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index 1eb7a6feedbdd0e033f6c9738c65d22d646713c4..e917292251a288e6d40cd3dd5fbf7a5964e6a3aa 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -106,7 +106,7 @@ class RecorderOutput(StreamOutput): format=RECORDER_CONTAINER_FORMAT, container_options={ "video_track_timescale": str(int(1 / source_v.time_base)), - "movflags": "frag_keyframe", + "movflags": "frag_keyframe+empty_moov", "min_frag_duration": str( self.stream_settings.min_segment_duration ), diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 83a5a1c1c9d4fd1c79ec3a3d2f129906e8831fb5..4829cf7208784bb41da0fb81d75aff06cc64eb59 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -31,6 +31,8 @@ from .const import ( VEHICLE_HAS_REMOTE_START, VEHICLE_HAS_SAFETY_SERVICE, VEHICLE_LAST_UPDATE, + VEHICLE_MODEL_NAME, + VEHICLE_MODEL_YEAR, VEHICLE_NAME, VEHICLE_VIN, ) @@ -147,6 +149,8 @@ def get_vehicle_info(controller, vin): """Obtain vehicle identifiers and capabilities.""" info = { VEHICLE_VIN: vin, + VEHICLE_MODEL_NAME: controller.get_model_name(vin), + VEHICLE_MODEL_YEAR: controller.get_model_year(vin), VEHICLE_NAME: controller.vin_to_name(vin), VEHICLE_HAS_EV: controller.get_ev_status(vin), VEHICLE_API_GEN: controller.get_api_gen(vin), @@ -163,5 +167,6 @@ def get_device_info(vehicle_info): return DeviceInfo( identifiers={(DOMAIN, vehicle_info[VEHICLE_VIN])}, manufacturer=MANUFACTURER, + model=f"{vehicle_info[VEHICLE_MODEL_YEAR]} {vehicle_info[VEHICLE_MODEL_NAME]}", name=vehicle_info[VEHICLE_NAME], ) diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py index 79c412c8f85b38debdd57811bafea5cec939e2a5..6d1d5015ed3f8c38f6e42c78fb96ca8948bcd3c4 100644 --- a/homeassistant/components/subaru/config_flow.py +++ b/homeassistant/components/subaru/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime import logging +from typing import Any from subarulink import ( Controller as SubaruAPI, @@ -16,6 +17,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import CONF_COUNTRY, CONF_UPDATE_ENABLED, DOMAIN @@ -36,7 +38,9 @@ class SubaruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.config_data = {CONF_PIN: None} self.controller = None - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" error = None @@ -117,7 +121,9 @@ class SubaruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug("Successfully authenticated with Subaru API") self.config_data.update(data) - async def async_step_two_factor(self, user_input=None): + async def async_step_two_factor( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Select contact method and request 2FA code from Subaru.""" error = None if user_input: @@ -143,7 +149,9 @@ class SubaruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="two_factor", data_schema=data_schema, errors=error ) - async def async_step_two_factor_validate(self, user_input=None): + async def async_step_two_factor_validate( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Validate received 2FA code with Subaru.""" error = None if user_input: @@ -166,7 +174,9 @@ class SubaruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="two_factor_validate", data_schema=data_schema, errors=error ) - async def async_step_pin(self, user_input=None): + async def async_step_pin( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle second part of config flow, if required.""" error = None if user_input and self.controller.update_saved_pin(user_input[CONF_PIN]): @@ -193,7 +203,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Initialize options flow.""" 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: """Handle options flow.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index 3ad7dd58af55eeb6afdee54da3715a14ac909ab8..3de4930a691771bbaf7298a39712f718ef37e118 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -19,6 +19,8 @@ COORDINATOR_NAME = "subaru_data" # info fields VEHICLE_VIN = "vin" +VEHICLE_MODEL_NAME = "model_name" +VEHICLE_MODEL_YEAR = "model_year" VEHICLE_NAME = "display_name" VEHICLE_HAS_EV = "is_ev" VEHICLE_API_GEN = "api_gen" @@ -31,7 +33,7 @@ VEHICLE_STATUS = "status" API_GEN_1 = "g1" API_GEN_2 = "g2" -MANUFACTURER = "Subaru Corp." +MANUFACTURER = "Subaru" PLATFORMS = [ Platform.LOCK, diff --git a/homeassistant/components/subaru/entity.py b/homeassistant/components/subaru/entity.py deleted file mode 100644 index 2bdb1425b2d64c2bf93348ce9d2e11169f74083e..0000000000000000000000000000000000000000 --- a/homeassistant/components/subaru/entity.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Base class for all Subaru Entities.""" -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN, MANUFACTURER, VEHICLE_NAME, VEHICLE_VIN - - -class SubaruEntity(CoordinatorEntity): - """Representation of a Subaru Entity.""" - - def __init__(self, vehicle_info, coordinator): - """Initialize the Subaru Entity.""" - super().__init__(coordinator) - self.car_name = vehicle_info[VEHICLE_NAME] - self.vin = vehicle_info[VEHICLE_VIN] - self.entity_type = "entity" - - @property - def name(self): - """Return name.""" - return f"{self.car_name} {self.entity_type}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self.vin}_{self.entity_type}" - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.vin)}, - manufacturer=MANUFACTURER, - name=self.car_name, - ) diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 0123f26f91698d550e1a687c0a4353568bef0d08..df3a97cbda3290b6215a9b18ade8e880c2b4d6a8 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -3,7 +3,7 @@ "name": "Subaru", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/subaru", - "requirements": ["subarulink==0.5.0"], + "requirements": ["subarulink==0.6.1"], "codeowners": ["@G-Two"], "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"] diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index f1a1ea96382935784fa907098840581546b309dd..cae5a7b14a4363415b5408df66a46a7c2ff3563d 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -1,10 +1,16 @@ """Support for Subaru sensors.""" +from __future__ import annotations + +import logging +from typing import Any + import subarulink.const as sc from homeassistant.components.sensor import ( - DEVICE_CLASSES, SensorDeviceClass, SensorEntity, + SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -14,128 +20,142 @@ from homeassistant.const import ( PERCENTAGE, PRESSURE_HPA, TEMP_CELSIUS, - TIME_MINUTES, VOLUME_GALLONS, VOLUME_LITERS, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from homeassistant.util.unit_conversion import DistanceConverter, VolumeConverter from homeassistant.util.unit_system import ( - IMPERIAL_SYSTEM, LENGTH_UNITS, PRESSURE_UNITS, - TEMPERATURE_UNITS, + US_CUSTOMARY_SYSTEM, ) +from . import get_device_info from .const import ( API_GEN_2, DOMAIN, ENTRY_COORDINATOR, ENTRY_VEHICLES, - ICONS, VEHICLE_API_GEN, VEHICLE_HAS_EV, VEHICLE_HAS_SAFETY_SERVICE, VEHICLE_STATUS, + VEHICLE_VIN, ) -from .entity import SubaruEntity -L_PER_GAL = VolumeConverter.convert(1, VOLUME_GALLONS, VOLUME_LITERS) -KM_PER_MI = DistanceConverter.convert(1, LENGTH_MILES, LENGTH_KILOMETERS) +_LOGGER = logging.getLogger(__name__) -# Fuel Economy Constants -FUEL_CONSUMPTION_L_PER_100KM = "L/100km" -FUEL_CONSUMPTION_MPG = "mi/gal" -FUEL_CONSUMPTION_UNITS = [FUEL_CONSUMPTION_L_PER_100KM, FUEL_CONSUMPTION_MPG] -SENSOR_TYPE = "type" -SENSOR_CLASS = "class" -SENSOR_FIELD = "field" -SENSOR_UNITS = "units" +# Fuel consumption units +FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS = "L/100km" +FUEL_CONSUMPTION_MILES_PER_GALLON = "mi/gal" -# Sensor data available to "Subaru Safety Plus" subscribers with Gen1 or Gen2 vehicles +L_PER_GAL = VolumeConverter.convert(1, VOLUME_GALLONS, VOLUME_LITERS) +KM_PER_MI = DistanceConverter.convert(1, LENGTH_MILES, LENGTH_KILOMETERS) + +# Sensor available to "Subaru Safety Plus" subscribers with Gen1 or Gen2 vehicles SAFETY_SENSORS = [ - { - SENSOR_TYPE: "Odometer", - SENSOR_CLASS: None, - SENSOR_FIELD: sc.ODOMETER, - SENSOR_UNITS: LENGTH_KILOMETERS, - }, + SensorEntityDescription( + key=sc.ODOMETER, + device_class=SensorDeviceClass.DISTANCE, + icon="mdi:road-variant", + name="Odometer", + native_unit_of_measurement=LENGTH_KILOMETERS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), ] -# Sensor data available to "Subaru Safety Plus" subscribers with Gen2 vehicles +# Sensors available to "Subaru Safety Plus" subscribers with Gen2 vehicles API_GEN_2_SENSORS = [ - { - SENSOR_TYPE: "Avg Fuel Consumption", - SENSOR_CLASS: None, - SENSOR_FIELD: sc.AVG_FUEL_CONSUMPTION, - SENSOR_UNITS: FUEL_CONSUMPTION_L_PER_100KM, - }, - { - SENSOR_TYPE: "Range", - SENSOR_CLASS: None, - SENSOR_FIELD: sc.DIST_TO_EMPTY, - SENSOR_UNITS: LENGTH_KILOMETERS, - }, - { - SENSOR_TYPE: "Tire Pressure FL", - SENSOR_CLASS: SensorDeviceClass.PRESSURE, - SENSOR_FIELD: sc.TIRE_PRESSURE_FL, - SENSOR_UNITS: PRESSURE_HPA, - }, - { - SENSOR_TYPE: "Tire Pressure FR", - SENSOR_CLASS: SensorDeviceClass.PRESSURE, - SENSOR_FIELD: sc.TIRE_PRESSURE_FR, - SENSOR_UNITS: PRESSURE_HPA, - }, - { - SENSOR_TYPE: "Tire Pressure RL", - SENSOR_CLASS: SensorDeviceClass.PRESSURE, - SENSOR_FIELD: sc.TIRE_PRESSURE_RL, - SENSOR_UNITS: PRESSURE_HPA, - }, - { - SENSOR_TYPE: "Tire Pressure RR", - SENSOR_CLASS: SensorDeviceClass.PRESSURE, - SENSOR_FIELD: sc.TIRE_PRESSURE_RR, - SENSOR_UNITS: PRESSURE_HPA, - }, - { - SENSOR_TYPE: "External Temp", - SENSOR_CLASS: SensorDeviceClass.TEMPERATURE, - SENSOR_FIELD: sc.EXTERNAL_TEMP, - SENSOR_UNITS: TEMP_CELSIUS, - }, - { - SENSOR_TYPE: "12V Battery Voltage", - SENSOR_CLASS: SensorDeviceClass.VOLTAGE, - SENSOR_FIELD: sc.BATTERY_VOLTAGE, - SENSOR_UNITS: ELECTRIC_POTENTIAL_VOLT, - }, + SensorEntityDescription( + key=sc.AVG_FUEL_CONSUMPTION, + icon="mdi:leaf", + name="Avg fuel consumption", + native_unit_of_measurement=FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=sc.DIST_TO_EMPTY, + device_class=SensorDeviceClass.DISTANCE, + icon="mdi:gas-station", + name="Range", + native_unit_of_measurement=LENGTH_KILOMETERS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=sc.TIRE_PRESSURE_FL, + device_class=SensorDeviceClass.PRESSURE, + name="Tire pressure FL", + native_unit_of_measurement=PRESSURE_HPA, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=sc.TIRE_PRESSURE_FR, + device_class=SensorDeviceClass.PRESSURE, + name="Tire pressure FR", + native_unit_of_measurement=PRESSURE_HPA, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=sc.TIRE_PRESSURE_RL, + device_class=SensorDeviceClass.PRESSURE, + name="Tire pressure RL", + native_unit_of_measurement=PRESSURE_HPA, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=sc.TIRE_PRESSURE_RR, + device_class=SensorDeviceClass.PRESSURE, + name="Tire pressure RR", + native_unit_of_measurement=PRESSURE_HPA, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=sc.EXTERNAL_TEMP, + device_class=SensorDeviceClass.TEMPERATURE, + name="External temp", + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=sc.BATTERY_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + name="12V battery voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), ] -# Sensor data available to "Subaru Safety Plus" subscribers with PHEV vehicles +# Sensors available to "Subaru Safety Plus" subscribers with PHEV vehicles EV_SENSORS = [ - { - SENSOR_TYPE: "EV Range", - SENSOR_CLASS: None, - SENSOR_FIELD: sc.EV_DISTANCE_TO_EMPTY, - SENSOR_UNITS: LENGTH_MILES, - }, - { - SENSOR_TYPE: "EV Battery Level", - SENSOR_CLASS: SensorDeviceClass.BATTERY, - SENSOR_FIELD: sc.EV_STATE_OF_CHARGE_PERCENT, - SENSOR_UNITS: PERCENTAGE, - }, - { - SENSOR_TYPE: "EV Time to Full Charge", - SENSOR_CLASS: SensorDeviceClass.TIMESTAMP, - SENSOR_FIELD: sc.EV_TIME_TO_FULLY_CHARGED, - SENSOR_UNITS: TIME_MINUTES, - }, + SensorEntityDescription( + key=sc.EV_DISTANCE_TO_EMPTY, + device_class=SensorDeviceClass.DISTANCE, + icon="mdi:ev-station", + name="EV range", + native_unit_of_measurement=LENGTH_MILES, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=sc.EV_STATE_OF_CHARGE_PERCENT, + device_class=SensorDeviceClass.BATTERY, + name="EV battery level", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=sc.EV_TIME_TO_FULLY_CHARGED_UTC, + device_class=SensorDeviceClass.TIMESTAMP, + name="EV time to full charge", + state_class=SensorStateClass.MEASUREMENT, + ), ] @@ -145,123 +165,112 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Subaru sensors by config_entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][ENTRY_COORDINATOR] - vehicle_info = hass.data[DOMAIN][config_entry.entry_id][ENTRY_VEHICLES] + entry = hass.data[DOMAIN][config_entry.entry_id] + coordinator = entry[ENTRY_COORDINATOR] + vehicle_info = entry[ENTRY_VEHICLES] entities = [] - for vin in vehicle_info: - entities.extend(create_vehicle_sensors(vehicle_info[vin], coordinator)) - async_add_entities(entities, True) + await _async_migrate_entries(hass, config_entry) + for info in vehicle_info.values(): + entities.extend(create_vehicle_sensors(info, coordinator)) + async_add_entities(entities) -def create_vehicle_sensors(vehicle_info, coordinator): +def create_vehicle_sensors( + vehicle_info, coordinator: DataUpdateCoordinator +) -> list[SubaruSensor]: """Instantiate all available sensors for the vehicle.""" - sensors_to_add = [] + sensor_descriptions_to_add = [] if vehicle_info[VEHICLE_HAS_SAFETY_SERVICE]: - sensors_to_add.extend(SAFETY_SENSORS) + sensor_descriptions_to_add.extend(SAFETY_SENSORS) if vehicle_info[VEHICLE_API_GEN] == API_GEN_2: - sensors_to_add.extend(API_GEN_2_SENSORS) + sensor_descriptions_to_add.extend(API_GEN_2_SENSORS) if vehicle_info[VEHICLE_HAS_EV]: - sensors_to_add.extend(EV_SENSORS) + sensor_descriptions_to_add.extend(EV_SENSORS) return [ SubaruSensor( vehicle_info, coordinator, - s[SENSOR_TYPE], - s[SENSOR_CLASS], - s[SENSOR_FIELD], - s[SENSOR_UNITS], + description, ) - for s in sensors_to_add + for description in sensor_descriptions_to_add ] -class SubaruSensor(SubaruEntity, SensorEntity): +class SubaruSensor( + CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]], SensorEntity +): """Class for Subaru sensors.""" + _attr_has_entity_name = True + def __init__( - self, vehicle_info, coordinator, entity_type, sensor_class, data_field, api_unit - ): + self, + vehicle_info: dict, + coordinator: DataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: """Initialize the sensor.""" - super().__init__(vehicle_info, coordinator) - self.hass_type = "sensor" - self.current_value = None - self.entity_type = entity_type - self.sensor_class = sensor_class - self.data_field = data_field - self.api_unit = api_unit + super().__init__(coordinator) + self.vin = vehicle_info[VEHICLE_VIN] + self.entity_description = description + self._attr_device_info = get_device_info(vehicle_info) + self._attr_unique_id = f"{self.vin}_{description.key}" @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - if self.sensor_class in DEVICE_CLASSES: - return self.sensor_class - return None - - @property - def icon(self): - """Return the icon of the sensor.""" - if not self.device_class: - return ICONS.get(self.entity_type) - return None - - @property - def native_value(self): + def native_value(self) -> None | int | float: """Return the state of the sensor.""" - self.current_value = self.get_current_value() + vehicle_data = self.coordinator.data[self.vin] + current_value = vehicle_data[VEHICLE_STATUS].get(self.entity_description.key) + unit = self.entity_description.native_unit_of_measurement + unit_system = self.hass.config.units - if self.current_value is None: + if current_value is None: return None - if self.api_unit in TEMPERATURE_UNITS: - return round( - self.hass.config.units.temperature(self.current_value, self.api_unit), 1 - ) + if unit in LENGTH_UNITS: + return round(unit_system.length(current_value, unit), 1) - if self.api_unit in LENGTH_UNITS: + if unit in PRESSURE_UNITS and unit_system == US_CUSTOMARY_SYSTEM: return round( - self.hass.config.units.length(self.current_value, self.api_unit), 1 - ) - - if ( - self.api_unit in PRESSURE_UNITS - and self.hass.config.units == IMPERIAL_SYSTEM - ): - return round( - self.hass.config.units.pressure(self.current_value, self.api_unit), + unit_system.pressure(current_value, unit), 1, ) if ( - self.api_unit in FUEL_CONSUMPTION_UNITS - and self.hass.config.units == IMPERIAL_SYSTEM + unit + in [ + FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, + FUEL_CONSUMPTION_MILES_PER_GALLON, + ] + and unit_system == US_CUSTOMARY_SYSTEM ): - return round((100.0 * L_PER_GAL) / (KM_PER_MI * self.current_value), 1) + return round((100.0 * L_PER_GAL) / (KM_PER_MI * current_value), 1) - return self.current_value + return current_value @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit_of_measurement of the device.""" - if self.api_unit in TEMPERATURE_UNITS: - return self.hass.config.units.temperature_unit + unit = self.entity_description.native_unit_of_measurement - if self.api_unit in LENGTH_UNITS: + if unit in LENGTH_UNITS: return self.hass.config.units.length_unit - if self.api_unit in PRESSURE_UNITS: - if self.hass.config.units == IMPERIAL_SYSTEM: + if unit in PRESSURE_UNITS: + if self.hass.config.units == US_CUSTOMARY_SYSTEM: return self.hass.config.units.pressure_unit - return PRESSURE_HPA - if self.api_unit in FUEL_CONSUMPTION_UNITS: - if self.hass.config.units == IMPERIAL_SYSTEM: - return FUEL_CONSUMPTION_MPG - return FUEL_CONSUMPTION_L_PER_100KM + if unit in [ + FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, + FUEL_CONSUMPTION_MILES_PER_GALLON, + ]: + if self.hass.config.units == US_CUSTOMARY_SYSTEM: + return FUEL_CONSUMPTION_MILES_PER_GALLON - return self.api_unit + return unit @property def available(self) -> bool: @@ -271,14 +280,47 @@ class SubaruSensor(SubaruEntity, SensorEntity): return False return last_update_success - def get_current_value(self): - """Get raw value from the coordinator.""" - value = self.coordinator.data[self.vin][VEHICLE_STATUS].get(self.data_field) - if value in sc.BAD_SENSOR_VALUES: - value = None - if isinstance(value, str): - if "." in value: - value = float(value) - else: - value = int(value) - return value + +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate sensor entries from HA<=2022.10 to use preferred unique_id.""" + entity_registry = er.async_get(hass) + + all_sensors = [] + all_sensors.extend(EV_SENSORS) + all_sensors.extend(API_GEN_2_SENSORS) + all_sensors.extend(SAFETY_SENSORS) + + # Old unique_id is (previously title-cased) sensor name (e.g. "VIN_Avg Fuel Consumption") + replacements = {str(s.name).upper(): s.key for s in all_sensors} + + @callback + def update_unique_id(entry: er.RegistryEntry) -> dict[str, Any] | None: + id_split = entry.unique_id.split("_") + key = id_split[1].upper() if len(id_split) == 2 else None + + if key not in replacements or id_split[1] == replacements[key]: + return None + + new_unique_id = entry.unique_id.replace(id_split[1], replacements[key]) + _LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + _LOGGER.debug( + "Cannot migrate to unique_id '%s', already exists for '%s'", + new_unique_id, + existing_entity_id, + ) + return None + return { + "new_unique_id": new_unique_id, + } + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 77b1de6555e33ff1ea4390cf5e2e56a1b28e08fd..4c0fe16a197abf9606150a8a4211ea3c71b75975 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -93,7 +93,8 @@ class SuezSensor(SensorEntity): # _state holds the volume of consumed water during previous day self._state = self.client.state self._available = True - self._attributes["attribution"] = self.client.attributes["attribution"] + self._attr_attribution = self.client.attributes["attribution"] + self._attributes["this_month_consumption"] = {} for item in self.client.attributes["thisMonthConsumption"]: self._attributes["this_month_consumption"][ diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 256df2f8971d8cda85399f2b33c59e71bc8b2972..65836e0c619deb11e015acba951c8246e491debb 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -9,7 +9,6 @@ from astral.location import Elevation, Location from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - CONF_ELEVATION, EVENT_CORE_CONFIG_UPDATE, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, @@ -82,11 +81,6 @@ _PHASE_UPDATES = { async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track the state of the sun.""" - if config.get(CONF_ELEVATION) is not None: - _LOGGER.warning( - "Elevation is now configured in Home Assistant core. " - "See https://www.home-assistant.io/docs/configuration/basic/" - ) hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, diff --git a/homeassistant/components/sun/translations/bg.json b/homeassistant/components/sun/translations/bg.json index 81ead95c95fd6ff7af19beafa4aed8a4545e9438..cc1a533ab9d282e80cda759e0d2346ce1c57bf53 100644 --- a/homeassistant/components/sun/translations/bg.json +++ b/homeassistant/components/sun/translations/bg.json @@ -2,6 +2,11 @@ "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." + }, + "step": { + "user": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u0437\u0430\u043f\u043e\u0447\u043d\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0442\u0430?" + } } }, "state": { diff --git a/homeassistant/components/surepetcare/translations/nb.json b/homeassistant/components/surepetcare/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/surepetcare/translations/nb.json +++ b/homeassistant/components/surepetcare/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py index 7c14428c2fc1025407e00ad2f717ef3f38f7a9bd..dfa8b8fba045b60f9c20b8e99ef69ae4acdf3e59 100644 --- a/homeassistant/components/swiss_hydrological_data/sensor.py +++ b/homeassistant/components/swiss_hydrological_data/sensor.py @@ -8,7 +8,7 @@ from swisshydrodata import SwissHydroData import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -17,8 +17,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by the Swiss Federal Office for the Environment FOEN" - ATTR_MAX_24H = "max-24h" ATTR_MEAN_24H = "mean-24h" ATTR_MIN_24H = "min-24h" @@ -85,6 +83,10 @@ def setup_platform( class SwissHydrologicalDataSensor(SensorEntity): """Implementation of a Swiss hydrological sensor.""" + _attr_attribution = ( + "Data provided by the Swiss Federal Office for the Environment FOEN" + ) + def __init__(self, hydro_data, station, condition): """Initialize the Swiss hydrological sensor.""" self.hydro_data = hydro_data @@ -123,7 +125,7 @@ class SwissHydrologicalDataSensor(SensorEntity): attrs = {} if not self._data: - attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + return attrs attrs[ATTR_WATER_BODY_TYPE] = self._data["water-body-type"] @@ -131,7 +133,6 @@ class SwissHydrologicalDataSensor(SensorEntity): attrs[ATTR_STATION_UPDATE] = self._data["parameters"][self._condition][ "datetime" ] - attrs[ATTR_ATTRIBUTION] = ATTRIBUTION for entry in CONDITION_DETAILS: attrs[entry.replace("-", "_")] = self._data["parameters"][self._condition][ diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index e23b8cd3aeb06d8f1baa51d18adb38c3675f283d..8735726f89292669da496a53432eed456c825177 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -9,7 +9,7 @@ from opendata_transport.exceptions import OpendataTransportError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -30,8 +30,6 @@ ATTR_TRAIN_NUMBER = "train_number" ATTR_TRANSFERS = "transfers" ATTR_DELAY = "delay" -ATTRIBUTION = "Data provided by transport.opendata.ch" - CONF_DESTINATION = "to" CONF_START = "from" @@ -80,6 +78,8 @@ async def async_setup_platform( class SwissPublicTransportSensor(SensorEntity): """Implementation of an Swiss public transport sensor.""" + _attr_attribution = "Data provided by transport.opendata.ch" + def __init__(self, opendata, start, destination, name): """Initialize the sensor.""" self._opendata = opendata @@ -122,7 +122,6 @@ class SwissPublicTransportSensor(SensorEntity): ATTR_START: self._opendata.from_name, ATTR_TARGET: self._opendata.to_name, ATTR_REMAINING_TIME: f"{self._remaining_time}", - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_DELAY: self._opendata.connections[0]["delay"], } diff --git a/homeassistant/components/switch/translations/sv.json b/homeassistant/components/switch/translations/sv.json index 6a87682ae5d751d68fa86e80f3588350f1b8d562..d9456d76c61945316bd3c49128abb9889bcfa226 100644 --- a/homeassistant/components/switch/translations/sv.json +++ b/homeassistant/components/switch/translations/sv.json @@ -21,5 +21,5 @@ "on": "P\u00e5" } }, - "title": "Kontakt" + "title": "Brytare" } \ No newline at end of file diff --git a/homeassistant/components/switch_as_x/translations/nb.json b/homeassistant/components/switch_as_x/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..6c72f96873e16cdb5d2169243aa5304a34430469 --- /dev/null +++ b/homeassistant/components/switch_as_x/translations/nb.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "entity_id": "Bryter", + "target_domain": "Ny type" + }, + "description": "Velg en bryter du vil vise i Home Assistant som lys, deksel eller noe annet. Den opprinnelige bryteren vil v\u00e6re skjult." + } + } + }, + "title": "Endre enhetstype for en bryter" +} \ No newline at end of file diff --git a/homeassistant/components/switch_as_x/translations/sv.json b/homeassistant/components/switch_as_x/translations/sv.json index 95ea5abe41094c39f2223de0b5723a3a9354dc11..9ff8e548255858a32126dc73ae07d7b3326e6a8f 100644 --- a/homeassistant/components/switch_as_x/translations/sv.json +++ b/homeassistant/components/switch_as_x/translations/sv.json @@ -10,5 +10,5 @@ } } }, - "title": "Kontakt som X" + "title": "\u00c4ndra enhetstyp f\u00f6r en c" } \ No newline at end of file diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index d841121889bef5a8d7d5681993593b02fa6b9ef6..5848477ec71cad15d5884755698e96916e9bad21 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -13,7 +13,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import SwitchBeeCoordinator -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.LIGHT, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.CLIMATE, + Platform.COVER, + Platform.LIGHT, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -22,7 +28,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: central_unit = entry.data[CONF_HOST] user = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] - websession = async_get_clientsession(hass, verify_ssl=False) api = CentralUnitAPI(central_unit, user, password, websession) try: diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py new file mode 100644 index 0000000000000000000000000000000000000000..8d0024b75ed4f666e55066ec402c921b5e11208d --- /dev/null +++ b/homeassistant/components/switchbee/climate.py @@ -0,0 +1,182 @@ +"""Support for SwitchBee climate.""" +from __future__ import annotations + +from typing import Any + +from switchbee.api import SwitchBeeDeviceOfflineError, SwitchBeeError +from switchbee.const import ( + ApiAttribute, + ThermostatFanSpeed, + ThermostatMode, + ThermostatTemperatureUnit, +) +from switchbee.device import ApiStateCommand, SwitchBeeThermostat + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import SwitchBeeCoordinator +from .entity import SwitchBeeDeviceEntity + +FAN_SB_TO_HASS = { + ThermostatFanSpeed.AUTO: FAN_AUTO, + ThermostatFanSpeed.LOW: FAN_LOW, + ThermostatFanSpeed.MEDIUM: FAN_MEDIUM, + ThermostatFanSpeed.HIGH: FAN_HIGH, +} + +FAN_HASS_TO_SB: dict[str | None, str] = { + FAN_AUTO: ThermostatFanSpeed.AUTO, + FAN_LOW: ThermostatFanSpeed.LOW, + FAN_MEDIUM: ThermostatFanSpeed.MEDIUM, + FAN_HIGH: ThermostatFanSpeed.HIGH, +} + +HVAC_MODE_SB_TO_HASS = { + ThermostatMode.COOL: HVACMode.COOL, + ThermostatMode.HEAT: HVACMode.HEAT, + ThermostatMode.FAN: HVACMode.FAN_ONLY, +} + +HVAC_MODE_HASS_TO_SB: dict[HVACMode | str | None, str] = { + HVACMode.COOL: ThermostatMode.COOL, + HVACMode.HEAT: ThermostatMode.HEAT, + HVACMode.FAN_ONLY: ThermostatMode.FAN, +} + +HVAC_ACTION_SB_TO_HASS = { + ThermostatMode.COOL: HVACAction.COOLING, + ThermostatMode.HEAT: HVACAction.HEATING, + ThermostatMode.FAN: HVACAction.FAN, +} + +HVAC_UNIT_SB_TO_HASS = { + ThermostatTemperatureUnit.CELSIUS: TEMP_CELSIUS, + ThermostatTemperatureUnit.FAHRENHEIT: TEMP_FAHRENHEIT, +} + +SUPPORTED_FAN_MODES = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up SwitchBee climate.""" + coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + SwitchBeeClimateEntity(switchbee_device, coordinator) + for switchbee_device in coordinator.data.values() + if isinstance(switchbee_device, SwitchBeeThermostat) + ) + + +class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], ClimateEntity): + """Representation of a SwitchBee climate.""" + + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + _attr_fan_modes = SUPPORTED_FAN_MODES + _attr_target_temperature_step = 1 + + def __init__( + self, + device: SwitchBeeThermostat, + coordinator: SwitchBeeCoordinator, + ) -> None: + """Initialize the Switchbee switch.""" + super().__init__(device, coordinator) + # set HVAC capabilities + self._attr_max_temp = device.max_temperature + self._attr_min_temp = device.min_temperature + self._attr_temperature_unit = HVAC_UNIT_SB_TO_HASS[device.unit] + self._attr_hvac_modes = [HVAC_MODE_SB_TO_HASS[mode] for mode in device.modes] + self._attr_hvac_modes.append(HVACMode.OFF) + self._update_attrs_from_coordinator() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attrs_from_coordinator() + super()._handle_coordinator_update() + + def _update_attrs_from_coordinator(self) -> None: + + coordinator_device = self._get_coordinator_device() + + self._attr_hvac_mode: HVACMode = ( + HVACMode.OFF + if coordinator_device.state == ApiStateCommand.OFF + else HVAC_MODE_SB_TO_HASS[coordinator_device.mode] + ) + self._attr_fan_mode = FAN_SB_TO_HASS[coordinator_device.fan] + self._attr_current_temperature = coordinator_device.temperature + self._attr_target_temperature = coordinator_device.target_temperature + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + + if hvac_mode == HVACMode.OFF: + await self._operate(power=ApiStateCommand.OFF) + else: + await self._operate( + power=ApiStateCommand.ON, mode=HVAC_MODE_HASS_TO_SB[hvac_mode] + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + await self._operate(target_temperature=kwargs[ATTR_TEMPERATURE]) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set AC fan mode.""" + await self._operate(fan=FAN_HASS_TO_SB[fan_mode]) + + async def _operate( + self, + power: str | None = None, + mode: str | None = None, + fan: str | None = None, + target_temperature: int | None = None, + ) -> None: + """Send request to central unit.""" + + if power is None: + power = ApiStateCommand.ON + if self.hvac_mode == HVACMode.OFF: + power = ApiStateCommand.OFF + if mode is None: + mode = HVAC_MODE_HASS_TO_SB[self.hvac_mode] + if fan is None: + fan = FAN_HASS_TO_SB[self.fan_mode] + if target_temperature is None: + target_temperature = int(self.target_temperature or 0) + + state: dict[str, int | str] = { + ApiAttribute.POWER: power, + ApiAttribute.MODE: mode, + ApiAttribute.FAN: fan, + ApiAttribute.CONFIGURED_TEMPERATURE: target_temperature, + } + + try: + await self.coordinator.api.set_state(self._device.id, state) + except (SwitchBeeError, SwitchBeeDeviceOfflineError) as exp: + raise HomeAssistantError( + f"Failed to set {self.name} state {state}, error: {str(exp)}" + ) from exp + else: + await self.coordinator.async_refresh() diff --git a/homeassistant/components/switchbee/coordinator.py b/homeassistant/components/switchbee/coordinator.py index 1eddba27fd34ec54ca87440a5f8e73346d05377b..3dee30bac0ea39a40f4db164fc62274e6fe3da1c 100644 --- a/homeassistant/components/switchbee/coordinator.py +++ b/homeassistant/components/switchbee/coordinator.py @@ -62,6 +62,9 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic DeviceType.TimedPowerSwitch, DeviceType.Scenario, DeviceType.Dimmer, + DeviceType.Shutter, + DeviceType.Somfy, + DeviceType.Thermostat, ] ) except SwitchBeeError as exp: diff --git a/homeassistant/components/switchbee/cover.py b/homeassistant/components/switchbee/cover.py new file mode 100644 index 0000000000000000000000000000000000000000..ea5494f7f5b2d33675682697be0ab646086567d9 --- /dev/null +++ b/homeassistant/components/switchbee/cover.py @@ -0,0 +1,152 @@ +"""Support for SwitchBee cover.""" + +from __future__ import annotations + +from typing import Any + +from switchbee.api import SwitchBeeError, SwitchBeeTokenError +from switchbee.const import SomfyCommand +from switchbee.device import SwitchBeeShutter, SwitchBeeSomfy + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +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 DOMAIN +from .coordinator import SwitchBeeCoordinator +from .entity import SwitchBeeDeviceEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up SwitchBee switch.""" + coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[CoverEntity] = [] + + for device in coordinator.data.values(): + if isinstance(device, SwitchBeeShutter): + entities.append(SwitchBeeCoverEntity(device, coordinator)) + elif isinstance(device, SwitchBeeSomfy): + entities.append(SwitchBeeSomfyEntity(device, coordinator)) + + async_add_entities(entities) + + +class SwitchBeeSomfyEntity(SwitchBeeDeviceEntity[SwitchBeeSomfy], CoverEntity): + """Representation of a SwitchBee Somfy cover.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + _attr_supported_features = ( + CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN | CoverEntityFeature.STOP + ) + _attr_is_closed = None + + async def _fire_somfy_command(self, command: str) -> None: + """Async function to fire Somfy device command.""" + try: + await self.coordinator.api.set_state(self._device.id, command) + except (SwitchBeeError, SwitchBeeTokenError) as exp: + raise HomeAssistantError( + f"Failed to fire {command} for {self.name}, {str(exp)}" + ) from exp + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + return await self._fire_somfy_command(SomfyCommand.UP) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + return await self._fire_somfy_command(SomfyCommand.DOWN) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop a moving cover.""" + return await self._fire_somfy_command(SomfyCommand.MY) + + +class SwitchBeeCoverEntity(SwitchBeeDeviceEntity[SwitchBeeShutter], CoverEntity): + """Representation of a SwitchBee cover.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + _attr_supported_features = ( + CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + ) + _attr_is_closed = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_from_coordinator() + super()._handle_coordinator_update() + + def _update_from_coordinator(self) -> None: + """Update the entity attributes from the coordinator data.""" + + coordinator_device = self._get_coordinator_device() + + if coordinator_device.position == -1: + self._check_if_became_offline() + return + + # check if the device was offline (now online) and bring it back + self._check_if_became_online() + + self._attr_current_cover_position = coordinator_device.position + + if self.current_cover_position == 0: + self._attr_is_closed = True + else: + self._attr_is_closed = False + super()._handle_coordinator_update() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + if self.current_cover_position == 100: + return + + await self.async_set_cover_position(position=100) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + if self.current_cover_position == 0: + return + + await self.async_set_cover_position(position=0) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop a moving cover.""" + # to stop the shutter, we just interrupt it with any state during operation + await self.async_set_cover_position( + position=self.current_cover_position, force=True + ) + + # fetch data from the Central Unit to get the new position + await self.coordinator.async_request_refresh() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Async function to set position to cover.""" + if ( + self.current_cover_position == kwargs[ATTR_POSITION] + and "force" not in kwargs + ): + return + try: + await self.coordinator.api.set_state(self._device.id, kwargs[ATTR_POSITION]) + except (SwitchBeeError, SwitchBeeTokenError) as exp: + raise HomeAssistantError( + f"Failed to set {self.name} position to {kwargs[ATTR_POSITION]}, error: {str(exp)}" + ) from exp + + self._get_coordinator_device().position = kwargs[ATTR_POSITION] + self.coordinator.async_set_updated_data(self.coordinator.data) + self.async_write_ha_state() diff --git a/homeassistant/components/switchbee/entity.py b/homeassistant/components/switchbee/entity.py index af2d834fd2f4a1d8e2dfe56df53b4641d054c5e0..28248667c50d6e7b58168940ac7515f132e918de 100644 --- a/homeassistant/components/switchbee/entity.py +++ b/homeassistant/components/switchbee/entity.py @@ -1,10 +1,10 @@ """Support for SwitchBee entity.""" import logging -from typing import Generic, TypeVar +from typing import Generic, TypeVar, cast from switchbee import SWITCHBEE_BRAND from switchbee.api import SwitchBeeDeviceOfflineError, SwitchBeeError -from switchbee.device import SwitchBeeBaseDevice +from switchbee.device import DeviceType, SwitchBeeBaseDevice from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -46,12 +46,15 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): """Initialize the Switchbee device.""" super().__init__(device, coordinator) self._is_online: bool = True + identifier = ( + device.id if device.type == DeviceType.Thermostat else device.unit_id + ) self._attr_device_info = DeviceInfo( name=device.zone, identifiers={ ( DOMAIN, - f"{device.unit_id}-{coordinator.mac_formatted}", + f"{identifier}-{coordinator.mac_formatted}", ) }, manufacturer=SWITCHBEE_BRAND, @@ -108,3 +111,6 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): self.name, ) self._is_online = True + + def _get_coordinator_device(self) -> _DeviceTypeT: + return cast(_DeviceTypeT, self.coordinator.data[self._device.id]) diff --git a/homeassistant/components/switchbee/light.py b/homeassistant/components/switchbee/light.py index 4740da4cbbe3c5aea62668b8b57b590360b90bff..7bcf64598c125c9d50affcad32a498173206f6a6 100644 --- a/homeassistant/components/switchbee/light.py +++ b/homeassistant/components/switchbee/light.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any from switchbee.api import SwitchBeeDeviceOfflineError, SwitchBeeError from switchbee.device import ApiStateCommand, DeviceType, SwitchBeeDimmer @@ -72,9 +72,7 @@ class SwitchBeeLightEntity(SwitchBeeDeviceEntity[SwitchBeeDimmer], LightEntity): def _update_attrs_from_coordinator(self) -> None: - coordinator_device = cast( - SwitchBeeDimmer, self.coordinator.data[self._device.id] - ) + coordinator_device = self._get_coordinator_device() brightness = coordinator_device.brightness # module is offline @@ -112,7 +110,7 @@ class SwitchBeeLightEntity(SwitchBeeDeviceEntity[SwitchBeeDimmer], LightEntity): return # update the coordinator data manually we already know the Central Unit brightness data for this light - cast(SwitchBeeDimmer, self.coordinator.data[self._device.id]).brightness = state + self._get_coordinator_device().brightness = state self.coordinator.async_set_updated_data(self.coordinator.data) async def async_turn_off(self, **kwargs: Any) -> None: @@ -125,5 +123,5 @@ class SwitchBeeLightEntity(SwitchBeeDeviceEntity[SwitchBeeDimmer], LightEntity): ) from exp # update the coordinator manually - cast(SwitchBeeDimmer, self.coordinator.data[self._device.id]).brightness = 0 + self._get_coordinator_device().brightness = 0 self.coordinator.async_set_updated_data(self.coordinator.data) diff --git a/homeassistant/components/switchbee/manifest.json b/homeassistant/components/switchbee/manifest.json index 5ca066e3bc02d65123b0486e8d9d5f344f0280e9..75e5b2e9bfdaed94de9a56a4026063dfffb6fd5f 100644 --- a/homeassistant/components/switchbee/manifest.json +++ b/homeassistant/components/switchbee/manifest.json @@ -5,8 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbee", "requirements": ["pyswitchbee==1.5.5"], "codeowners": ["@jafar-atili"], - "iot_class": "local_polling", - "supported_brands": { - "bswitch": "BSwitch" - } + "iot_class": "local_polling" } diff --git a/homeassistant/components/switchbee/switch.py b/homeassistant/components/switchbee/switch.py index bb0a6123de2f1dc7cdbd08b8fddad17d140e8b5a..48fee37449cb1b9ea1e6a1f03b4ea2d6a25ed8f6 100644 --- a/homeassistant/components/switchbee/switch.py +++ b/homeassistant/components/switchbee/switch.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, TypeVar, Union, cast +from typing import Any, TypeVar, Union from switchbee.api import SwitchBeeDeviceOfflineError, SwitchBeeError from switchbee.device import ( @@ -76,7 +76,7 @@ class SwitchBeeSwitchEntity(SwitchBeeDeviceEntity[_DeviceTypeT], SwitchEntity): def _update_from_coordinator(self) -> None: """Update the entity attributes from the coordinator data.""" - coordinator_device = cast(_DeviceTypeT, self.coordinator.data[self._device.id]) + coordinator_device = self._get_coordinator_device() if coordinator_device.state == -1: diff --git a/homeassistant/components/switchbee/translations/nb.json b/homeassistant/components/switchbee/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..5e81332ac80876a0cfbb02dfa05ec3138d17b610 --- /dev/null +++ b/homeassistant/components/switchbee/translations/nb.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbee/translations/pt-BR.json b/homeassistant/components/switchbee/translations/pt-BR.json index 2b93ff92ac3b0c1eefb8614089f62a1c3085f3ec..a99ffe41150d96edf3a60a5bf5e9dde695dd951e 100644 --- a/homeassistant/components/switchbee/translations/pt-BR.json +++ b/homeassistant/components/switchbee/translations/pt-BR.json @@ -1,17 +1,17 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado" + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado" }, "error": { - "cannot_connect": "Falhou ao se conectar", + "cannot_connect": "Falha ao conectar", "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", "unknown": "Erro inesperado" }, "step": { "user": { "data": { - "host": "Host", + "host": "Nome do host", "password": "Senha", "switch_as_light": "Inicializar switches como entidades de luz", "username": "Nome de usu\u00e1rio" diff --git a/homeassistant/components/switchbee/translations/sv.json b/homeassistant/components/switchbee/translations/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..42d3330f48ece3f4afc26b357c0cf755a6101d45 --- /dev/null +++ b/homeassistant/components/switchbee/translations/sv.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "password": "L\u00f6senord", + "switch_as_light": "Initiera omkopplare som ljusenheter", + "username": "Anv\u00e4ndarnamn" + }, + "description": "Konfigurera SwitchBee-integrationen med Home Assistant." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "devices": "Enheter att inkludera" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbee/translations/tr.json b/homeassistant/components/switchbee/translations/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..b3bd4cde1b1a9bca7c6f99b6535beea9b29cf62f --- /dev/null +++ b/homeassistant/components/switchbee/translations/tr.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Sunucu", + "password": "Parola", + "switch_as_light": "Anahtarlar\u0131 \u0131\u015f\u0131k varl\u0131klar\u0131 olarak ba\u015flat", + "username": "Kullan\u0131c\u0131 Ad\u0131" + }, + "description": "Home Assistant ile SwitchBee entegrasyonunu kurun." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "devices": "Dahil edilecek cihazlar" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index a537802826438f4f74725b043ca6da565c2394fa..296cd4c18007be5bbc29c803904e1e1b6efec5c2 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -62,8 +62,6 @@ async def async_setup_entry( class SwitchBotBinarySensor(SwitchbotEntity, BinarySensorEntity): """Representation of a Switchbot binary sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: SwitchbotDataUpdateCoordinator, diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py index b8d08e74f5f260d7c0fcd95f820ff3baf9e3d53d..fcf0bdc4da2eb68d83ec5b49324099e49ffc8910 100644 --- a/homeassistant/components/switchbot/entity.py +++ b/homeassistant/components/switchbot/entity.py @@ -24,6 +24,7 @@ class SwitchbotEntity(PassiveBluetoothCoordinatorEntity): coordinator: SwitchbotDataUpdateCoordinator _device: SwitchbotDevice + _attr_has_entity_name = True def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: """Initialize the entity.""" @@ -32,7 +33,6 @@ class SwitchbotEntity(PassiveBluetoothCoordinatorEntity): self._last_run_success: bool | None = None self._address = coordinator.ble_device.address self._attr_unique_id = coordinator.base_unique_id - self._attr_name = coordinator.device_name self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_BLUETOOTH, self._address)}, manufacturer=MANUFACTURER, diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 282bf6aa447cf97c1d7a11b4221641371f96549a..532edac7d43163dc33ed20c08d6c6dcf91715d23 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.19.15"], + "requirements": ["PySwitchbot==0.20.2"], "config_flow": true, "dependencies": ["bluetooth"], "codeowners": [ diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index e435e71efbd2ad584adf2148cc09ba32d98b033f..9b1baf805bb4541c6f9c21b14c43ee8b40c2b9c2 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -1,6 +1,7 @@ """Support for SwitchBot sensors.""" from __future__ import annotations +from homeassistant.components.bluetooth import async_last_service_info from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -26,20 +27,25 @@ PARALLEL_UPDATES = 0 SENSOR_TYPES: dict[str, SensorEntityDescription] = { "rssi": SensorEntityDescription( key="rssi", + name="Bluetooth signal strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), "wifi_rssi": SensorEntityDescription( key="wifi_rssi", + name="Wi-Fi signal strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), "battery": SensorEntityDescription( key="battery", + name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, @@ -47,18 +53,21 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { ), "lightLevel": SensorEntityDescription( key="lightLevel", + name="Light level", native_unit_of_measurement="Level", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.ILLUMINANCE, ), "humidity": SensorEntityDescription( key="humidity", + name="Humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.HUMIDITY, ), "temperature": SensorEntityDescription( key="temperature", + name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, @@ -95,12 +104,10 @@ class SwitchBotSensor(SwitchbotEntity, SensorEntity): super().__init__(coordinator) self._sensor = sensor self._attr_unique_id = f"{coordinator.base_unique_id}-{sensor}" - name = coordinator.device_name - self._attr_name = f"{name} {sensor.replace('_', ' ').title()}" self.entity_description = SENSOR_TYPES[sensor] @property - def native_value(self) -> str | int: + def native_value(self) -> str | int | None: """Return the state of the sensor.""" return self.data["data"][self._sensor] @@ -109,6 +116,14 @@ class SwitchbotRSSISensor(SwitchBotSensor): """Representation of a Switchbot RSSI sensor.""" @property - def native_value(self) -> str | int: + def native_value(self) -> str | int | None: """Return the state of the sensor.""" - return self.coordinator.ble_device.rssi + # Switchbot supports both connectable and non-connectable devices + # so we need to request the rssi value based on the connectable instead + # of the nearest scanner since that is the RSSI that matters for controlling + # the device. + if service_info := async_last_service_info( + self.hass, self._address, self.coordinator.connectable + ): + return service_info.rssi + return None diff --git a/homeassistant/components/switchbot/translations/he.json b/homeassistant/components/switchbot/translations/he.json index 7f7974024b16cb6519df984d9c8231c49debac4d..b4cb968ff23b562baa2516adda9d8ca16b9db8e4 100644 --- a/homeassistant/components/switchbot/translations/he.json +++ b/homeassistant/components/switchbot/translations/he.json @@ -7,6 +7,11 @@ }, "flow_title": "{name} ({address})", "step": { + "password": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + }, "user": { "data": { "name": "\u05e9\u05dd", diff --git a/homeassistant/components/switchbot/translations/nb.json b/homeassistant/components/switchbot/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/switchbot/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 31273dce23d7557bbeab60d5cd529b07cc317325..be8f140711aed930553c5e2a28f3fc3309482bff 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -29,7 +29,7 @@ from .const import ( ) from .utils import async_start_bridge, async_stop_bridge -PLATFORMS = [Platform.SWITCH, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.COVER, Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py new file mode 100644 index 0000000000000000000000000000000000000000..99b9208e4aded3a6d82cdb14bf954fd1c338cc03 --- /dev/null +++ b/homeassistant/components/switcher_kis/climate.py @@ -0,0 +1,218 @@ +"""Switcher integration Climate platform.""" +from __future__ import annotations + +import asyncio +from typing import Any, cast + +from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api +from aioswitcher.api.remotes import SwitcherBreezeRemote, SwitcherBreezeRemoteManager +from aioswitcher.device import ( + DeviceCategory, + DeviceState, + ThermostatFanLevel, + ThermostatMode, + ThermostatSwing, +) + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + SWING_OFF, + SWING_VERTICAL, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import SwitcherDataUpdateCoordinator +from .const import SIGNAL_DEVICE_ADD + +DEVICE_MODE_TO_HA = { + ThermostatMode.COOL: HVACMode.COOL, + ThermostatMode.HEAT: HVACMode.HEAT, + ThermostatMode.FAN: HVACMode.FAN_ONLY, + ThermostatMode.DRY: HVACMode.DRY, + ThermostatMode.AUTO: HVACMode.HEAT_COOL, +} + +HA_TO_DEVICE_MODE = {value: key for key, value in DEVICE_MODE_TO_HA.items()} + +DEVICE_FAN_TO_HA = { + ThermostatFanLevel.LOW: FAN_LOW, + ThermostatFanLevel.MEDIUM: FAN_MEDIUM, + ThermostatFanLevel.HIGH: FAN_HIGH, + ThermostatFanLevel.AUTO: FAN_AUTO, +} + +HA_TO_DEVICE_FAN = {value: key for key, value in DEVICE_FAN_TO_HA.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Switcher climate from config entry.""" + remote_manager = SwitcherBreezeRemoteManager() + + async def async_add_climate(coordinator: SwitcherDataUpdateCoordinator) -> None: + """Get remote and add climate from Switcher device.""" + if coordinator.data.device_type.category == DeviceCategory.THERMOSTAT: + remote: SwitcherBreezeRemote = await hass.async_add_executor_job( + remote_manager.get_remote, coordinator.data.remote_id + ) + async_add_entities([SwitcherClimateEntity(coordinator, remote)]) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_climate) + ) + + +class SwitcherClimateEntity( + CoordinatorEntity[SwitcherDataUpdateCoordinator], ClimateEntity +): + """Representation of a Switcher climate entity.""" + + def __init__( + self, coordinator: SwitcherDataUpdateCoordinator, remote: SwitcherBreezeRemote + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._remote = remote + + self._attr_name = coordinator.name + self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" + self._attr_device_info = DeviceInfo( + connections={ + (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) + } + ) + + self._attr_min_temp = remote.min_temperature + self._attr_max_temp = remote.max_temperature + self._attr_target_temperature_step = 1 + self._attr_temperature_unit = TEMP_CELSIUS + + self._attr_supported_features = 0 + self._attr_hvac_modes = [HVACMode.OFF] + for mode in remote.modes_features: + self._attr_hvac_modes.append(DEVICE_MODE_TO_HA[mode]) + features = remote.modes_features[mode] + + if features["temperature_control"]: + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + + if features["fan_levels"]: + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE + + if features["swing"] and not remote.separated_swing_command: + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + self._update_data(True) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_data() + self.async_write_ha_state() + + def _update_data(self, force_update: bool = False) -> None: + """Update data from device.""" + data = self.coordinator.data + features = self._remote.modes_features[data.mode] + + if data.target_temperature == 0 and not force_update: + return + + self._attr_current_temperature = cast(float, data.temperature) + self._attr_target_temperature = float(data.target_temperature) + + self._attr_hvac_mode = HVACMode.OFF + if data.device_state == DeviceState.ON: + self._attr_hvac_mode = DEVICE_MODE_TO_HA[data.mode] + + self._attr_fan_mode = None + self._attr_fan_modes = [] + if features["fan_levels"]: + self._attr_fan_modes = [DEVICE_FAN_TO_HA[x] for x in features["fan_levels"]] + self._attr_fan_mode = DEVICE_FAN_TO_HA[data.fan_level] + + self._attr_swing_mode = None + self._attr_swing_modes = [] + if features["swing"]: + self._attr_swing_mode = SWING_OFF + self._attr_swing_modes = [SWING_VERTICAL, SWING_OFF] + if data.swing == ThermostatSwing.ON: + self._attr_swing_mode = SWING_VERTICAL + + async def _async_control_breeze_device(self, **kwargs: Any) -> None: + """Call Switcher Control Breeze API.""" + response: SwitcherBaseResponse = None + error = None + + try: + async with SwitcherType2Api( + self.coordinator.data.ip_address, self.coordinator.data.device_id + ) as swapi: + response = await swapi.control_breeze_device(self._remote, **kwargs) + except (asyncio.TimeoutError, OSError, RuntimeError) as err: + error = repr(err) + + if error or not response or not response.successful: + self.coordinator.last_update_success = False + self.async_write_ha_state() + raise HomeAssistantError( + f"Call Breeze control for {self.name} failed, " + f"response/error: {response or error}" + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if not self._remote.modes_features[self.coordinator.data.mode][ + "temperature_control" + ]: + raise HomeAssistantError( + "Current mode doesn't support setting Target Temperature" + ) + + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + raise ValueError("No target temperature provided") + + await self._async_control_breeze_device(target_temp=int(temperature)) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + if not self._remote.modes_features[self.coordinator.data.mode]["fan_levels"]: + raise HomeAssistantError("Current mode doesn't support setting Fan Mode") + + await self._async_control_breeze_device(fan_level=HA_TO_DEVICE_FAN[fan_mode]) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target operation mode.""" + if hvac_mode == hvac_mode.OFF: + await self._async_control_breeze_device(state=DeviceState.OFF) + else: + await self._async_control_breeze_device( + state=DeviceState.ON, mode=HA_TO_DEVICE_MODE[hvac_mode] + ) + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + if not self._remote.modes_features[self.coordinator.data.mode]["swing"]: + raise HomeAssistantError("Current mode doesn't support setting Swing Mode") + + if swing_mode == SWING_VERTICAL: + await self._async_control_breeze_device(swing=ThermostatSwing.ON) + else: + await self._async_control_breeze_device(swing=ThermostatSwing.OFF) diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py new file mode 100644 index 0000000000000000000000000000000000000000..584f3d7124fa3acd849077c406b6be34ce8b8916 --- /dev/null +++ b/homeassistant/components/switcher_kis/cover.py @@ -0,0 +1,130 @@ +"""Switcher integration Cover platform.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api +from aioswitcher.device import DeviceCategory, ShutterDirection, SwitcherShutter + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import SwitcherDataUpdateCoordinator +from .const import SIGNAL_DEVICE_ADD + +_LOGGER = logging.getLogger(__name__) + +API_SET_POSITON = "set_position" +API_STOP = "stop" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Switcher cover from config entry.""" + + @callback + def async_add_cover(coordinator: SwitcherDataUpdateCoordinator) -> None: + """Add cover from Switcher device.""" + if coordinator.data.device_type.category == DeviceCategory.SHUTTER: + async_add_entities([SwitcherCoverEntity(coordinator)]) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_cover) + ) + + +class SwitcherCoverEntity( + CoordinatorEntity[SwitcherDataUpdateCoordinator], CoverEntity +): + """Representation of a Switcher cover entity.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + ) + + def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._attr_name = coordinator.name + self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" + self._attr_device_info = DeviceInfo( + connections={ + (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) + } + ) + + self._update_data() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_data() + self.async_write_ha_state() + + def _update_data(self) -> None: + """Update data from device.""" + data: SwitcherShutter = self.coordinator.data + self._attr_current_cover_position = data.position + self._attr_is_closed = data.position == 0 + self._attr_is_closing = data.direction == ShutterDirection.SHUTTER_DOWN + self._attr_is_opening = data.direction == ShutterDirection.SHUTTER_UP + + async def _async_call_api(self, api: str, *args: Any) -> None: + """Call Switcher API.""" + _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) + response: SwitcherBaseResponse = None + error = None + + try: + async with SwitcherType2Api( + self.coordinator.data.ip_address, self.coordinator.data.device_id + ) as swapi: + response = await getattr(swapi, api)(*args) + except (asyncio.TimeoutError, OSError, RuntimeError) as err: + error = repr(err) + + if error or not response or not response.successful: + self.coordinator.last_update_success = False + self.async_write_ha_state() + raise HomeAssistantError( + f"Call api for {self.name} failed, api: '{api}', " + f"args: {args}, response/error: {response or error}" + ) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + await self._async_call_api(API_SET_POSITON, 0) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open cover.""" + await self._async_call_api(API_SET_POSITON, 100) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + await self._async_call_api(API_SET_POSITON, kwargs[ATTR_POSITION]) + + async def async_stop_cover(self, **_kwargs: Any) -> None: + """Stop the cover.""" + await self._async_call_api(API_STOP) diff --git a/homeassistant/components/switcher_kis/diagnostics.py b/homeassistant/components/switcher_kis/diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..93b3c36bd214453b85c959ef9b46b91b69bf6c3d --- /dev/null +++ b/homeassistant/components/switcher_kis/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for Switcher.""" +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DATA_DEVICE, DOMAIN + +TO_REDACT = {"device_id", "ip_address", "mac_address"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + devices = hass.data[DOMAIN][DATA_DEVICE] + + return async_redact_data( + { + "entry": entry.as_dict(), + "devices": [asdict(devices[d].data) for d in devices], + }, + TO_REDACT, + ) diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 30a2ac3bb48c50ff7a4f506217d780fe217d2e1a..14f324d8cace2002fced9d5540a0b3a77d905f29 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -3,7 +3,7 @@ "name": "Switcher", "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "codeowners": ["@tomerfi", "@thecode"], - "requirements": ["aioswitcher==3.0.0"], + "requirements": ["aioswitcher==3.1.0"], "quality_scale": "platinum", "iot_class": "local_push", "config_flow": true, diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 0065038954f1b0fa82f2bbd591c09e619f4d33c8..9d1b5d4bdc5085165f125dd8b141dc341f47322b 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any -from aioswitcher.api import Command, SwitcherApi, SwitcherBaseResponse +from aioswitcher.api import Command, SwitcherBaseResponse, SwitcherType1Api from aioswitcher.device import DeviceCategory, DeviceState import voluptuous as vol @@ -110,7 +110,7 @@ class SwitcherBaseSwitchEntity( error = None try: - async with SwitcherApi( + async with SwitcherType1Api( self.coordinator.data.ip_address, self.coordinator.data.device_id ) as swapi: response = await getattr(swapi, api)(*args) diff --git a/homeassistant/components/symfonisk/__init__.py b/homeassistant/components/symfonisk/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5b7ce387e29160c7eabf73c165e789bbefe43f02 --- /dev/null +++ b/homeassistant/components/symfonisk/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: IKEA SYMFONISK.""" diff --git a/homeassistant/components/symfonisk/manifest.json b/homeassistant/components/symfonisk/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..89c05fa49b08e290290d6e55e87012bee712ae2a --- /dev/null +++ b/homeassistant/components/symfonisk/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "symfonisk", + "name": "IKEA SYMFONISK", + "integration_type": "virtual", + "supported_by": "sonos" +} diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index 988c92de59385f342af0a3d6f8e2a8c18f197d19..5031f485ab375f11333869449e312322608f3b9e 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -69,6 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config_entry_id=entry.entry_id, configuration_url=printer.url, connections=device_connections(printer), + default_manufacturer="Samsung", identifiers=device_identifiers(printer), model=printer.model(), name=printer.hostname(), diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 788b1cb5761bcdb51f7d9573c4514a2cbf87e8fe..11e1403816e6bb4fa905bf79245e31d6dcb3df0d 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -55,7 +55,10 @@ async def async_setup_entry( supp_output_tray = printer.output_tray_status() name = config_entry.data[CONF_NAME] - entities: list[SyncThruSensor] = [SyncThruMainSensor(coordinator, name)] + entities: list[SyncThruSensor] = [ + SyncThruMainSensor(coordinator, name), + SyncThruActiveAlertSensor(coordinator, name), + ] for key in supp_toner: entities.append(SyncThruTonerSensor(coordinator, name, key)) @@ -166,7 +169,7 @@ class SyncThruTonerSensor(SyncThruSensor): class SyncThruDrumSensor(SyncThruSensor): - """Implementation of a Samsung Printer toner sensor platform.""" + """Implementation of a Samsung Printer drum sensor platform.""" def __init__(self, syncthru, name, color): """Initialize the sensor.""" @@ -214,7 +217,7 @@ class SyncThruInputTraySensor(SyncThruSensor): class SyncThruOutputTraySensor(SyncThruSensor): - """Implementation of a Samsung Printer input tray sensor platform.""" + """Implementation of a Samsung Printer output tray sensor platform.""" def __init__(self, syncthru, name, number): """Initialize the sensor.""" @@ -237,3 +240,18 @@ class SyncThruOutputTraySensor(SyncThruSensor): if tray_state == "": tray_state = "Ready" return tray_state + + +class SyncThruActiveAlertSensor(SyncThruSensor): + """Implementation of a Samsung Printer active alerts sensor platform.""" + + def __init__(self, syncthru, name): + """Initialize the sensor.""" + super().__init__(syncthru, name) + self._name = f"{name} Active Alerts" + self._id_suffix = "_active_alerts" + + @property + def native_value(self): + """Show number of active alerts.""" + return self.syncthru.raw().get("GXI_ACTIVE_ALERT_TOTAL") diff --git a/homeassistant/components/synology_dsm/translations/nb.json b/homeassistant/components/synology_dsm/translations/nb.json index 2ba01a2ddc45288aefab7099e50acca2b8070a8d..74cefe6d321c6263189df820562d1af19b2b6cfe 100644 --- a/homeassistant/components/synology_dsm/translations/nb.json +++ b/homeassistant/components/synology_dsm/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "reauth_confirm": { "data": { diff --git a/homeassistant/components/synology_dsm/translations/no.json b/homeassistant/components/synology_dsm/translations/no.json index 89ca80b168ee0691c7db829adfc1e8ae6fcaa6a9..8ce16f6019d87d484055afd3fd28a2502fa19c33 100644 --- a/homeassistant/components/synology_dsm/translations/no.json +++ b/homeassistant/components/synology_dsm/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "reconfigure_successful": "Omkonfigurasjonen var vellykket" }, "error": { diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index f9d6e09f0c6600996aeace3dbc1a2ae298a5b992..ab34e6b91b565997f620dc46ace42a237e4b9461 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -22,6 +22,7 @@ from homeassistant.const import ( FREQUENCY_MEGAHERTZ, PERCENTAGE, POWER_WATT, + REVOLUTIONS_PER_MINUTE, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -41,7 +42,6 @@ ATTR_TYPE: Final = "type" ATTR_USED: Final = "used" PIXELS: Final = "px" -RPM: Final = "RPM" @dataclass @@ -439,7 +439,7 @@ async def async_setup_entry( name=f"{gpu['name']} Fan Speed", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=RPM, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, icon="mdi:fan", value=lambda data, k=gpu["key"]: getattr( data.gpu, f"{k}_fan_speed" diff --git a/homeassistant/components/system_bridge/translations/bg.json b/homeassistant/components/system_bridge/translations/bg.json index ccf68c66d57daa7f868f057ae41270901c4883b6..25a1b280f57b15e2f1b9ae46d9dc265814c5ffaf 100644 --- a/homeassistant/components/system_bridge/translations/bg.json +++ b/homeassistant/components/system_bridge/translations/bg.json @@ -1,5 +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", + "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", + "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_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", diff --git a/homeassistant/components/system_bridge/translations/nb.json b/homeassistant/components/system_bridge/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/system_bridge/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/system_bridge/translations/no.json b/homeassistant/components/system_bridge/translations/no.json index 22ab7f91a57accd404f9f189635be27becbee36a..23b7aaba767965774ad94edca4037087231b653e 100644 --- a/homeassistant/components/system_bridge/translations/no.json +++ b/homeassistant/components/system_bridge/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "unknown": "Uventet feil" }, "error": { diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index b0d538a4ff8d33b99ad9bbc4208fce44b89ca32e..9f00009b322708e4d760214dad1c6c8b11a07e57 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -1,8 +1,11 @@ """Support for system log.""" +from __future__ import annotations + from collections import OrderedDict, deque import logging import re import traceback +from typing import Any, cast import voluptuous as vol @@ -55,7 +58,9 @@ SERVICE_WRITE_SCHEMA = vol.Schema( ) -def _figure_out_source(record, call_stack, paths_re): +def _figure_out_source( + record: logging.LogRecord, call_stack: list[tuple[str, int]], paths_re: re.Pattern +) -> tuple[str, int]: # If a stack trace exists, extract file names from the entire call stack. # The other case is when a regular "log" is made (without an attached @@ -80,20 +85,44 @@ def _figure_out_source(record, call_stack, paths_re): # Try to match with a file within Home Assistant if match := paths_re.match(pathname[0]): - return [match.group(1), pathname[1]] + return (cast(str, match.group(1)), pathname[1]) # Ok, we don't know what this is return (record.pathname, record.lineno) +def _safe_get_message(record: logging.LogRecord) -> str: + """Get message from record and handle exceptions. + + This code will be unreachable during a pytest run + because pytest installs a logging handler that + will prevent this code from being reached. + + Calling record.getMessage() can raise an exception + if the log message does not contain sufficient arguments. + + As there is no guarantees about which exceptions + that can be raised, we catch all exceptions and + return a generic message. + + This must be manually tested when changing the code. + """ + try: + return record.getMessage() + except Exception: # pylint: disable=broad-except + return f"Bad logger message: {record.msg} ({record.args})" + + class LogEntry: """Store HA log entries.""" - def __init__(self, record, stack, source): + def __init__(self, record: logging.LogRecord, source: tuple[str, int]) -> None: """Initialize a log entry.""" self.first_occurred = self.timestamp = record.created self.name = record.name self.level = record.levelname - self.message = deque([record.getMessage()], maxlen=5) + # See the docstring of _safe_get_message for why we need to do this. + # This must be manually tested when changing the code. + self.message = deque([_safe_get_message(record)], maxlen=5) self.exception = "" self.root_cause = None if record.exc_info: @@ -128,7 +157,7 @@ class DedupStore(OrderedDict): super().__init__() self.maxlen = maxlen - def add_entry(self, entry): + def add_entry(self, entry: LogEntry) -> None: """Add a new entry.""" key = entry.hash @@ -157,7 +186,9 @@ class DedupStore(OrderedDict): class LogErrorHandler(logging.Handler): """Log handler for error messages.""" - def __init__(self, hass, maxlen, fire_event, paths_re): + def __init__( + self, hass: HomeAssistant, maxlen: int, fire_event: bool, paths_re: re.Pattern + ) -> None: """Initialize a new LogErrorHandler.""" super().__init__() self.hass = hass @@ -165,7 +196,7 @@ class LogErrorHandler(logging.Handler): self.fire_event = fire_event self.paths_re = paths_re - def emit(self, record): + def emit(self, record: logging.LogRecord) -> None: """Save error and warning logs. Everything logged with error or warning is saved in local buffer. A @@ -176,9 +207,7 @@ class LogErrorHandler(logging.Handler): if not record.exc_info: stack = [(f[0], f[1]) for f in traceback.extract_stack()] - entry = LogEntry( - record, stack, _figure_out_source(record, stack, self.paths_re) - ) + entry = LogEntry(record, _figure_out_source(record, stack, self.paths_re)) self.records.add_entry(entry) if self.fire_event: self.hass.bus.fire(EVENT_SYSTEM_LOG, entry.to_dict()) @@ -240,8 +269,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @websocket_api.websocket_command({vol.Required("type"): "system_log/list"}) @callback def list_errors( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict -): + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: """List all possible diagnostic handlers.""" connection.send_result( msg["id"], diff --git a/homeassistant/components/system_log/manifest.json b/homeassistant/components/system_log/manifest.json index d31ce0d8485802c902c1dc748eabe6cdd0082ca8..abbc637037b67901f2509496ba14baf77eef3774 100644 --- a/homeassistant/components/system_log/manifest.json +++ b/homeassistant/components/system_log/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/system_log", "dependencies": [], "codeowners": [], - "quality_scale": "internal" + "quality_scale": "internal", + "integration_type": "system" } diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 8e9f6d3e8967382e53649c1a51c44152283bb01f..a2db68f11c7441991722595eda33687dd112cd50 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -2,7 +2,7 @@ "domain": "systemmonitor", "name": "System Monitor", "documentation": "https://www.home-assistant.io/integrations/systemmonitor", - "requirements": ["psutil==5.9.2"], + "requirements": ["psutil==5.9.3"], "codeowners": [], "iot_class": "local_push", "loggers": ["psutil"] diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index 0eff510051d19f2a133dc5064b6d48b433b5aebc..7f009c278fe35d605169fa0b65c30aa39ae2fa2b 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -90,8 +90,7 @@ async def async_setup_entry( ] ) - if entities: - async_add_entities(entities, True) + async_add_entities(entities, True) class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 16a433b1d121e71c8156db0cea40e95c0c7ea1a3..5f58203e9e84046c343ead8c8696e717edda02df 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -104,8 +104,7 @@ async def async_setup_entry( "set_temp_offset", ) - if entities: - async_add_entities(entities, True) + async_add_entities(entities, True) def _generate_entities(tado): diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 22f4608f6704b2b7d35d5c116c575884bb419d5c..6d11e15a9fd1c787b08920008e69772500dbe1d8 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -82,8 +82,7 @@ async def async_setup_entry( ] ) - if entities: - async_add_entities(entities, True) + async_add_entities(entities, True) class TadoHomeSensor(TadoHomeEntity, SensorEntity): diff --git a/homeassistant/components/tado/translations/nb.json b/homeassistant/components/tado/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/tado/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index 3bd3ae48026a35744039ae00f059206241f4e093..92551df8109e71eaaec23eaccf0baa8391d481c8 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -75,8 +75,7 @@ async def async_setup_entry( "set_timer", ) - if entities: - async_add_entities(entities, True) + async_add_entities(entities, True) def _generate_entities(tado): diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json index 3249705ce7ced690e022b59e0df28987b07c7e3b..d59af231dfea1a04cece8f1b396ce3ecfa420181 100644 --- a/homeassistant/components/tailscale/manifest.json +++ b/homeassistant/components/tailscale/manifest.json @@ -6,5 +6,6 @@ "requirements": ["tailscale==0.2.0"], "codeowners": ["@frenck"], "quality_scale": "platinum", + "integration_type": "hub", "iot_class": "cloud_polling" } diff --git a/homeassistant/components/tailscale/translations/no.json b/homeassistant/components/tailscale/translations/no.json index 5c1ae4c6bc03f5e036a49c2d0ca339a6ec44eb13..609589aec6ee183f88afcbfccc8e9b7dd82095bd 100644 --- a/homeassistant/components/tailscale/translations/no.json +++ b/homeassistant/components/tailscale/translations/no.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index c63b0ea0e7ed63b5c1873398350cf85f17c275d1..e3b2ca9554b6f6493785c772a42dd9b71954a46b 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -5,12 +5,7 @@ import logging from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_LATITUDE, - ATTR_LONGITUDE, - CURRENCY_EURO, -) +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -62,6 +57,7 @@ async def async_setup_entry( class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): """Contains prices for fuel in a given station.""" + _attr_attribution = ATTRIBUTION _attr_state_class = SensorStateClass.MEASUREMENT _attr_icon = "mdi:gas-station" @@ -74,7 +70,6 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): self._attr_native_unit_of_measurement = CURRENCY_EURO self._attr_unique_id = f"{station['id']}_{fuel_type}" attrs = { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_BRAND: station["brand"], ATTR_FUEL_TYPE: fuel_type, ATTR_STATION_NAME: station["name"], diff --git a/homeassistant/components/tankerkoenig/translations/no.json b/homeassistant/components/tankerkoenig/translations/no.json index 369ac4d3ce41765c3c985c1d67cd9f2a4132d274..f0eac9a8f0eb2ada9c2f48a6a06565050db3fa83 100644 --- a/homeassistant/components/tankerkoenig/translations/no.json +++ b/homeassistant/components/tankerkoenig/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Plasseringen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 1878e2794f3a2772594fbf474f232444285068a2..09b44eca1ee127e667a5a934b425678fac4b6dce 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -52,7 +52,8 @@ DEVICE_CLASS = "device_class" STATE_CLASS = "state_class" ICON = "icon" -# A Tasmota sensor type may be mapped to either a device class or an icon, not both +# A Tasmota sensor type may be mapped to either a device class or an icon, +# both can only be set if the default device class icon is not appropriate SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { hc.SENSOR_ACTIVE_ENERGYEXPORT: { DEVICE_CLASS: SensorDeviceClass.ENERGY, @@ -91,6 +92,7 @@ SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { hc.SENSOR_COLOR_RED: {ICON: "mdi:palette"}, hc.SENSOR_CURRENT: { ICON: "mdi:alpha-a-circle-outline", + DEVICE_CLASS: SensorDeviceClass.CURRENT, STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_CURRENTNEUTRAL: { @@ -105,6 +107,7 @@ SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { }, hc.SENSOR_DISTANCE: { ICON: "mdi:leak", + DEVICE_CLASS: SensorDeviceClass.DISTANCE, STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_ECO2: {ICON: "mdi:molecule-co2"}, @@ -122,7 +125,10 @@ SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { }, hc.SENSOR_STATUS_IP: {ICON: "mdi:ip-network"}, hc.SENSOR_STATUS_LINK_COUNT: {ICON: "mdi:counter"}, - hc.SENSOR_MOISTURE: {ICON: "mdi:cup-water"}, + hc.SENSOR_MOISTURE: { + DEVICE_CLASS: SensorDeviceClass.MOISTURE, + ICON: "mdi:cup-water", + }, hc.SENSOR_STATUS_MQTT_COUNT: {ICON: "mdi:counter"}, hc.SENSOR_PB0_3: {ICON: "mdi:flask"}, hc.SENSOR_PB0_5: {ICON: "mdi:flask"}, @@ -144,6 +150,7 @@ SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { }, hc.SENSOR_POWERFACTOR: { ICON: "mdi:alpha-f-circle-outline", + DEVICE_CLASS: SensorDeviceClass.POWER_FACTOR, STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_POWERUSAGE: { @@ -158,7 +165,7 @@ SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { DEVICE_CLASS: SensorDeviceClass.PRESSURE, STATE_CLASS: SensorStateClass.MEASUREMENT, }, - hc.SENSOR_PROXIMITY: {ICON: "mdi:ruler"}, + hc.SENSOR_PROXIMITY: {DEVICE_CLASS: SensorDeviceClass.DISTANCE, ICON: "mdi:ruler"}, hc.SENSOR_REACTIVE_ENERGYEXPORT: {STATE_CLASS: SensorStateClass.TOTAL}, hc.SENSOR_REACTIVE_ENERGYIMPORT: {STATE_CLASS: SensorStateClass.TOTAL}, hc.SENSOR_REACTIVE_POWERUSAGE: { @@ -194,7 +201,11 @@ SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { ICON: "mdi:alpha-v-circle-outline", STATE_CLASS: SensorStateClass.MEASUREMENT, }, - hc.SENSOR_WEIGHT: {ICON: "mdi:scale", STATE_CLASS: SensorStateClass.MEASUREMENT}, + hc.SENSOR_WEIGHT: { + ICON: "mdi:scale", + DEVICE_CLASS: SensorDeviceClass.WEIGHT, + STATE_CLASS: SensorStateClass.MEASUREMENT, + }, hc.SENSOR_YESTERDAY: {DEVICE_CLASS: SensorDeviceClass.ENERGY}, } diff --git a/homeassistant/components/tasmota/translations/et.json b/homeassistant/components/tasmota/translations/et.json index 09ba6e5c328cb053255b24b67f282e1c5ddc560c..7eae86713271aa027854e5a4cbfdeda28a569a08 100644 --- a/homeassistant/components/tasmota/translations/et.json +++ b/homeassistant/components/tasmota/translations/et.json @@ -16,5 +16,15 @@ "description": "Kas soovid seadistada Tasmota sidumist?" } } + }, + "issues": { + "topic_duplicated": { + "description": "Mitmed Tasmota seadmed jagavad teemat {topic} . \n\n Selle probleemiga Tasmota seadmed: {offenders} .", + "title": "Mitu Tasmota seadet jagavad sama teemat" + }, + "topic_no_prefix": { + "description": "Tasmota seade {name} koos IP-ga {ip} ei sisalda t\u00e4isteemas '%prefix%'.\n\nSelle seadme olemid on keelatud, kuni konfiguratsioon on parandatud.", + "title": "Tasmota seadmel {name} on sobimatu MQTT teema" + } } } \ No newline at end of file diff --git a/homeassistant/components/tasmota/translations/nl.json b/homeassistant/components/tasmota/translations/nl.json index a116e8409770b771c59b5ba004e045184a6dc0d3..116f7142d637c2479771527f265584862060c226 100644 --- a/homeassistant/components/tasmota/translations/nl.json +++ b/homeassistant/components/tasmota/translations/nl.json @@ -16,5 +16,10 @@ "description": "Wil je Tasmota instellen?" } } + }, + "issues": { + "topic_no_prefix": { + "title": "Het Tasmota-apparaat {name} heeft een ongeldig MQTT-topic" + } } } \ No newline at end of file diff --git a/homeassistant/components/tasmota/translations/sv.json b/homeassistant/components/tasmota/translations/sv.json index df8bff409464110c697052f74f953f92b5181523..e33005865c27036dee42abecdf9fa5aecfcfe938 100644 --- a/homeassistant/components/tasmota/translations/sv.json +++ b/homeassistant/components/tasmota/translations/sv.json @@ -16,5 +16,15 @@ "description": "Vill du konfigurera Tasmota?" } } + }, + "issues": { + "topic_duplicated": { + "description": "Flera Tasmota-enheter delar \u00e4mnet {topic} . \n\n Tasmota-enheter med detta problem: {offenders} .", + "title": "Flera Tasmota-enheter delar samma \u00e4mne" + }, + "topic_no_prefix": { + "description": "Tasmota-enhet {name} med IP {ip} inkluderar inte ` %prefix% ` i hela \u00e4mnet. \n\n Entiteter f\u00f6r denna enhet \u00e4r inaktiverade tills konfigurationen har korrigerats.", + "title": "Tasmota-enheten {name} har ett ogiltigt MQTT-\u00e4mne" + } } } \ No newline at end of file diff --git a/homeassistant/components/tasmota/translations/tr.json b/homeassistant/components/tasmota/translations/tr.json index 71c38ef1bc0954a120b79ca954053e41d535cb9b..2de7d34dcf42ccc01b3f0027ead04be0207fd35a 100644 --- a/homeassistant/components/tasmota/translations/tr.json +++ b/homeassistant/components/tasmota/translations/tr.json @@ -16,5 +16,15 @@ "description": "Tasmota'y\u0131 kurmak istiyor musunuz?" } } + }, + "issues": { + "topic_duplicated": { + "description": "Birka\u00e7 Tasmota cihaz\u0131 {topic} konusunu payla\u015f\u0131yor. \n\n Bu soruna sahip Tasmota cihazlar\u0131: {offenders} .", + "title": "Birka\u00e7 Tasmota cihaz\u0131 ayn\u0131 konuyu payla\u015f\u0131yor" + }, + "topic_no_prefix": { + "description": "{ip} IP'sine sahip Tasmota cihaz\u0131 {name} , konusunun tamam\u0131nda ` %prefix% ` i\u00e7ermiyor. \n\n Bu cihazlar i\u00e7in varl\u0131klar, konfig\u00fcrasyon d\u00fczeltilene kadar devre d\u0131\u015f\u0131 b\u0131rak\u0131l\u0131r.", + "title": "{name} Tasmota cihaz\u0131nda ge\u00e7ersiz bir MQTT konusu var" + } } } \ No newline at end of file diff --git a/homeassistant/components/tautulli/translations/bg.json b/homeassistant/components/tautulli/translations/bg.json index fb4e836f98ddeb45189aafbbfe6b1fbd23f232bb..8f8e92fc4290b183acf5dde4984555c97093301e 100644 --- a/homeassistant/components/tautulli/translations/bg.json +++ b/homeassistant/components/tautulli/translations/bg.json @@ -1,6 +1,7 @@ { "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", "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", "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." }, diff --git a/homeassistant/components/tautulli/translations/cs.json b/homeassistant/components/tautulli/translations/cs.json index 45e0200110569e38350f27fd5f9df74f4cde1657..e65f964f82d950cebd1f26e09df0dae2c04df6c7 100644 --- a/homeassistant/components/tautulli/translations/cs.json +++ b/homeassistant/components/tautulli/translations/cs.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Slu\u017eba je ji\u017e nastavena", "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { diff --git a/homeassistant/components/tautulli/translations/el.json b/homeassistant/components/tautulli/translations/el.json index a83662fb6e68fbd4b3ecd3a41baccb886e0eebe0..6f1054584356f08bfaa4035055d840c75bbef6e4 100644 --- a/homeassistant/components/tautulli/translations/el.json +++ b/homeassistant/components/tautulli/translations/el.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u0397 \u03c5\u03c0\u03b7\u03c1\u03b5\u03c3\u03af\u03b1 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03c4\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", "single_instance_allowed": "\u0388\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03c4\u03b5\u03af. \u039c\u03cc\u03bd\u03bf \u03bc\u03af\u03b1 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae." }, diff --git a/homeassistant/components/tautulli/translations/et.json b/homeassistant/components/tautulli/translations/et.json index bc690db7a28118a2801a7047236862d2c1b24e9f..30ef733c9766012503611ebbbd9c3e04bda91c0f 100644 --- a/homeassistant/components/tautulli/translations/et.json +++ b/homeassistant/components/tautulli/translations/et.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Teenus on juba h\u00e4\u00e4lestatud", "reauth_successful": "Taastuvastamine \u00f5nnestus", "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." }, diff --git a/homeassistant/components/tautulli/translations/fr.json b/homeassistant/components/tautulli/translations/fr.json index 05b66af097224b3f1da650bc1ebf791c2d11dff7..ad9c327c551cfa672ec637edb484c275b611facf 100644 --- a/homeassistant/components/tautulli/translations/fr.json +++ b/homeassistant/components/tautulli/translations/fr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." }, diff --git a/homeassistant/components/tautulli/translations/he.json b/homeassistant/components/tautulli/translations/he.json index 80d0bba902b880966ae6be5a2c9d606ba28527a1..7091be81520a435334da835e9183d5f3ac4ec3c1 100644 --- a/homeassistant/components/tautulli/translations/he.json +++ b/homeassistant/components/tautulli/translations/he.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", "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." }, diff --git a/homeassistant/components/tautulli/translations/hu.json b/homeassistant/components/tautulli/translations/hu.json index b654081ecd1a30819e3b8389cb3fa6625b8bffc1..7d31ad678f24ba7070e95d6c5f4ef59355ca54ac 100644 --- a/homeassistant/components/tautulli/translations/hu.json +++ b/homeassistant/components/tautulli/translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt.", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, diff --git a/homeassistant/components/tautulli/translations/id.json b/homeassistant/components/tautulli/translations/id.json index c2042dacffaa96730aefd4aca991ec54e7697a58..18669b36f29655b1438a340843f9f2c8644a87df 100644 --- a/homeassistant/components/tautulli/translations/id.json +++ b/homeassistant/components/tautulli/translations/id.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Layanan sudah dikonfigurasi", "reauth_successful": "Autentikasi ulang berhasil", "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." }, diff --git a/homeassistant/components/tautulli/translations/it.json b/homeassistant/components/tautulli/translations/it.json index 7dcfb1dd0574ecd570d1f1147d4771cab900b17a..fcc456a876360e286b0636632710f276069c3588 100644 --- a/homeassistant/components/tautulli/translations/it.json +++ b/homeassistant/components/tautulli/translations/it.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Il servizio \u00e8 gi\u00e0 configurato", "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, diff --git a/homeassistant/components/tautulli/translations/ja.json b/homeassistant/components/tautulli/translations/ja.json index 2407bb4b984c443333e06c498f83b3aae297d828..fd51dc92c43d49eec39f9252a5472011df31f088 100644 --- a/homeassistant/components/tautulli/translations/ja.json +++ b/homeassistant/components/tautulli/translations/ja.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u30b5\u30fc\u30d3\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", "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u8a2d\u5b9a\u3067\u304d\u308b\u306e\u306f1\u3064\u3060\u3051\u3067\u3059\u3002" }, diff --git a/homeassistant/components/tautulli/translations/nb.json b/homeassistant/components/tautulli/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/tautulli/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tautulli/translations/no.json b/homeassistant/components/tautulli/translations/no.json index cd6667b26fe3bb9b3d1c23608572f0be1b7d83b0..0528a97beb9b759bcea9edbbc53a127eb034def0 100644 --- a/homeassistant/components/tautulli/translations/no.json +++ b/homeassistant/components/tautulli/translations/no.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "already_configured": "Tjenesten er allerede konfigurert", + "reauth_successful": "Re-autentisering var vellykket", "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "error": { diff --git a/homeassistant/components/tautulli/translations/pl.json b/homeassistant/components/tautulli/translations/pl.json index 25684ac6b3ce9ad0ee28e74a237bc825bd2b6b64..6dac9a7961760168302d936a82b236a4774c0a20 100644 --- a/homeassistant/components/tautulli/translations/pl.json +++ b/homeassistant/components/tautulli/translations/pl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Us\u0142uga jest ju\u017c skonfigurowana", "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." }, diff --git a/homeassistant/components/tautulli/translations/pt-BR.json b/homeassistant/components/tautulli/translations/pt-BR.json index 45c5b508f9620eece20485eeabfb7aee7b790e96..e5732024e3ad8469e20d9ff1d6e91f6a4f278154 100644 --- a/homeassistant/components/tautulli/translations/pt-BR.json +++ b/homeassistant/components/tautulli/translations/pt-BR.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "O servi\u00e7o j\u00e1 est\u00e1 configurado", "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida", "single_instance_allowed": "J\u00e1 configurado. Apenas uma configura\u00e7\u00e3o \u00e9 poss\u00edvel." }, diff --git a/homeassistant/components/tautulli/translations/sv.json b/homeassistant/components/tautulli/translations/sv.json index abcbe307998be6d23fbfa6b89c065de1f5216afe..1d4ad84cf587bde0cfc232398a012a58160cbceb 100644 --- a/homeassistant/components/tautulli/translations/sv.json +++ b/homeassistant/components/tautulli/translations/sv.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Tj\u00e4nsten \u00e4r redan konfigurerad", "reauth_successful": "\u00c5terautentisering lyckades", "single_instance_allowed": "Redan konfigurerad. Endast en konfiguration m\u00f6jlig." }, diff --git a/homeassistant/components/tautulli/translations/tr.json b/homeassistant/components/tautulli/translations/tr.json index b52d2a7abadceb9fd2aa1ec4a6d260b928a0eef4..e5fd6c14b6794b75d262c005f26c9eb8aa311653 100644 --- a/homeassistant/components/tautulli/translations/tr.json +++ b/homeassistant/components/tautulli/translations/tr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu", "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." }, diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index e2995620fb1cd0b445bc24fbe7ecd2186f6aa792..ed3efeb434424b97d34e128a73f73fac196e4173 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -14,10 +14,10 @@ from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, POWER_WATT, - PRECIPITATION_MILLIMETERS_PER_HOUR, SPEED_METERS_PER_SECOND, TEMP_CELSIUS, UV_INDEX, + UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -57,9 +57,9 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { SENSOR_TYPE_RAINRATE: SensorEntityDescription( key=SENSOR_TYPE_RAINRATE, name="Rain rate", - native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, - icon="mdi:water", + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SENSOR_TYPE_RAINTOTAL: SensorEntityDescription( key=SENSOR_TYPE_RAINTOTAL, diff --git a/homeassistant/components/tellduslive/translations/nb.json b/homeassistant/components/tellduslive/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/tellduslive/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/temper/manifest.json b/homeassistant/components/temper/manifest.json index b71bbe915632b13164cdddb17cfc6effa50984f5..72d998a9d4ade381396c5badf5303a02241a3e79 100644 --- a/homeassistant/components/temper/manifest.json +++ b/homeassistant/components/temper/manifest.json @@ -2,7 +2,7 @@ "domain": "temper", "name": "TEMPer", "documentation": "https://www.home-assistant.io/integrations/temper", - "requirements": ["temperusb==1.5.3"], + "requirements": ["temperusb==1.6.0"], "codeowners": [], "iot_class": "local_polling", "loggers": ["pyusb", "temperusb"] diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index b60e7f5336427076476a3e8639435fb5f6a16736..b27a6ee3e51dc6f4aa235ab4a54f922dd1406da2 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -22,7 +22,6 @@ from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, - STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -58,7 +57,6 @@ CONF_SET_OSCILLATING_ACTION = "set_oscillating" CONF_SET_DIRECTION_ACTION = "set_direction" CONF_SET_PRESET_MODE_ACTION = "set_preset_mode" -_VALID_STATES = [STATE_ON, STATE_OFF] _VALID_DIRECTIONS = [DIRECTION_FORWARD, DIRECTION_REVERSE] FAN_SCHEMA = vol.All( @@ -66,7 +64,7 @@ FAN_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_PERCENTAGE_TEMPLATE): cv.template, vol.Optional(CONF_PRESET_MODE_TEMPLATE): cv.template, vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template, @@ -144,7 +142,7 @@ class TemplateFan(TemplateEntity, FanEntity): ) friendly_name = self._attr_name - self._template = config[CONF_VALUE_TEMPLATE] + self._template = config.get(CONF_VALUE_TEMPLATE) self._percentage_template = config.get(CONF_PERCENTAGE_TEMPLATE) self._preset_mode_template = config.get(CONF_PRESET_MODE_TEMPLATE) self._oscillating_template = config.get(CONF_OSCILLATING_TEMPLATE) @@ -178,7 +176,7 @@ class TemplateFan(TemplateEntity, FanEntity): hass, set_direction_action, friendly_name, DOMAIN ) - self._state = STATE_OFF + self._state: bool | None = False self._percentage = None self._preset_mode = None self._oscillating = None @@ -215,9 +213,9 @@ class TemplateFan(TemplateEntity, FanEntity): return self._preset_modes @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if device is on.""" - return self._state == STATE_ON + return self._state @property def preset_mode(self) -> str | None: @@ -254,23 +252,27 @@ class TemplateFan(TemplateEntity, FanEntity): }, context=self._context, ) - self._state = STATE_ON if preset_mode is not None: await self.async_set_preset_mode(preset_mode) elif percentage is not None: await self.async_set_percentage(percentage) + if self._template is None: + self._state = True + self.async_write_ha_state() + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" await self.async_run_script(self._off_script, context=self._context) - self._state = STATE_OFF + + if self._template is None: + self._state = False + self.async_write_ha_state() async def async_set_percentage(self, percentage: int) -> None: """Set the percentage speed of the fan.""" - self._state = STATE_OFF if percentage == 0 else STATE_ON self._percentage = percentage - self._preset_mode = None if self._set_percentage_script: await self.async_run_script( @@ -279,6 +281,10 @@ class TemplateFan(TemplateEntity, FanEntity): context=self._context, ) + if self._template is None: + self._state = percentage != 0 + self.async_write_ha_state() + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset_mode of the fan.""" if self.preset_modes and preset_mode not in self.preset_modes: @@ -290,9 +296,7 @@ class TemplateFan(TemplateEntity, FanEntity): ) return - self._state = STATE_ON self._preset_mode = preset_mode - self._percentage = None if self._set_preset_mode_script: await self.async_run_script( @@ -301,6 +305,10 @@ class TemplateFan(TemplateEntity, FanEntity): context=self._context, ) + if self._template is None: + self._state = True + self.async_write_ha_state() + async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation of the fan.""" if self._set_oscillating_script is None: @@ -340,23 +348,23 @@ class TemplateFan(TemplateEntity, FanEntity): self._state = None return - # Validate state - if result in _VALID_STATES: + if isinstance(result, bool): self._state = result - elif result in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self._state = None - else: - _LOGGER.error( - "Received invalid fan is_on state: %s for entity %s. Expected: %s", - result, - self.entity_id, - ", ".join(_VALID_STATES), - ) - self._state = None + return + + if isinstance(result, str): + self._state = result.lower() in ("true", STATE_ON) + return + + self._state = False async def async_added_to_hass(self) -> None: """Register callbacks.""" - self.add_template_attribute("_state", self._template, None, self._update_state) + if self._template: + self.add_template_attribute( + "_state", self._template, None, self._update_state + ) + if self._preset_mode_template is not None: self.add_template_attribute( "_preset_mode", @@ -396,19 +404,17 @@ class TemplateFan(TemplateEntity, FanEntity): # Validate percentage try: percentage = int(float(percentage)) - except ValueError: + except (ValueError, TypeError): _LOGGER.error( "Received invalid percentage: %s for entity %s", percentage, self.entity_id, ) self._percentage = 0 - self._preset_mode = None return if 0 <= percentage <= 100: self._percentage = percentage - self._preset_mode = None else: _LOGGER.error( "Received invalid percentage: %s for entity %s", @@ -416,7 +422,6 @@ class TemplateFan(TemplateEntity, FanEntity): self.entity_id, ) self._percentage = 0 - self._preset_mode = None @callback def _update_preset_mode(self, preset_mode): @@ -424,10 +429,8 @@ class TemplateFan(TemplateEntity, FanEntity): preset_mode = str(preset_mode) if self.preset_modes and preset_mode in self.preset_modes: - self._percentage = None self._preset_mode = preset_mode elif preset_mode in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self._percentage = None self._preset_mode = None else: _LOGGER.error( @@ -436,7 +439,6 @@ class TemplateFan(TemplateEntity, FanEntity): self.entity_id, self.preset_mode, ) - self._percentage = None self._preset_mode = None @callback @@ -471,3 +473,8 @@ class TemplateFan(TemplateEntity, FanEntity): ", ".join(_VALID_DIRECTIONS), ) self._direction = None + + @property + def assumed_state(self) -> bool: + """State is assumed, if no template given.""" + return self._template is None diff --git a/homeassistant/components/tesla_wall_connector/translations/nb.json b/homeassistant/components/tesla_wall_connector/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index 639d23620265e7907d96855d9eb7422884f22ddb..34321a66681fbd05cb199e1a0813b6af7e2f5f6e 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -27,9 +27,5 @@ "requirements": ["thermobeacon-ble==0.3.2"], "dependencies": ["bluetooth"], "codeowners": ["@bdraco"], - "iot_class": "local_push", - "supported_brands": { - "thermoplus": "ThermoPlus", - "sensorblue": "SensorBlue" - } + "iot_class": "local_push" } diff --git a/homeassistant/components/thermobeacon/translations/he.json b/homeassistant/components/thermobeacon/translations/he.json index 47308062d0d426cb13dd9e46494762b2e48d2482..b182a698234a65d0e9dfc6fead874529e3d529b7 100644 --- a/homeassistant/components/thermobeacon/translations/he.json +++ b/homeassistant/components/thermobeacon/translations/he.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "not_supported": "\u05d4\u05ea\u05e7\u05df \u05d0\u05d9\u05e0\u05d5 \u05e0\u05ea\u05de\u05da" + }, "flow_title": "{name}", "step": { "bluetooth_confirm": { diff --git a/homeassistant/components/thermobeacon/translations/hu.json b/homeassistant/components/thermobeacon/translations/hu.json index 97fbb5b940835353812ebfe6bb39907626ddb6e9..4668ffea41696296cf59192c6561163e058b2a49 100644 --- a/homeassistant/components/thermobeacon/translations/hu.json +++ b/homeassistant/components/thermobeacon/translations/hu.json @@ -15,7 +15,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/thermoplus/manifest.json b/homeassistant/components/thermoplus/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..512cf216c05539764c70835bf4f2334f0227a676 --- /dev/null +++ b/homeassistant/components/thermoplus/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "thermoplus", + "name": "ThermoPlus", + "integration_type": "virtual", + "supported_by": "thermobeacon" +} diff --git a/homeassistant/components/thermopro/translations/hu.json b/homeassistant/components/thermopro/translations/hu.json index 7ef0d3a63013dc9a7c1814fe3c80d99ab7dede60..e1673194c6d885ee4f8bae1faa811bd0f14444f2 100644 --- a/homeassistant/components/thermopro/translations/hu.json +++ b/homeassistant/components/thermopro/translations/hu.json @@ -14,7 +14,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/threshold/translations/nb.json b/homeassistant/components/threshold/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..1800e628f3162dff458f26245dab617e727fa309 --- /dev/null +++ b/homeassistant/components/threshold/translations/nb.json @@ -0,0 +1,38 @@ +{ + "config": { + "error": { + "need_lower_upper": "Nedre og \u00f8vre grense kan ikke begge v\u00e6re tomme" + }, + "step": { + "user": { + "data": { + "entity_id": "Inngangssensor", + "hysteresis": "Hysterese", + "lower": "Nedre grense", + "name": "Navn", + "upper": "\u00d8vre grense" + }, + "description": "Lag en bin\u00e6r sensor som sl\u00e5s av og p\u00e5 avhengig av verdien til en sensor \n\n Kun nedre grense konfigurert - Sl\u00e5 p\u00e5 n\u00e5r inngangssensorens verdi er mindre enn den nedre grensen.\n Bare \u00f8vre grense konfigurert - Sl\u00e5 p\u00e5 n\u00e5r inngangssensorens verdi er st\u00f8rre enn den \u00f8vre grensen.\n B\u00e5de nedre og \u00f8vre grense konfigurert - Sl\u00e5 p\u00e5 n\u00e5r inngangssensorens verdi er innenfor omr\u00e5det [nedre grense .. \u00f8vre grense].", + "title": "Legg til terskelsensor" + } + } + }, + "options": { + "error": { + "need_lower_upper": "Nedre og \u00f8vre grense kan ikke begge v\u00e6re tomme" + }, + "step": { + "init": { + "data": { + "entity_id": "Inngangssensor", + "hysteresis": "Hysterese", + "lower": "Nedre grense", + "name": "Navn", + "upper": "\u00d8vre grense" + }, + "description": "Kun nedre grense konfigurert - Sl\u00e5 p\u00e5 n\u00e5r inngangssensorens verdi er mindre enn den nedre grensen.\n Bare \u00f8vre grense konfigurert - Sl\u00e5 p\u00e5 n\u00e5r inngangssensorens verdi er st\u00f8rre enn den \u00f8vre grensen.\n B\u00e5de nedre og \u00f8vre grense konfigurert - Sl\u00e5 p\u00e5 n\u00e5r inngangssensorens verdi er innenfor omr\u00e5det [nedre grense .. \u00f8vre grense]." + } + } + }, + "title": "Terskelsensor" +} \ No newline at end of file diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 34f5843412ee42ef8029e3b23ebc661de23ff6d8..35507986f90d2ec658b34863d21baff7128e8cd4 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -46,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hass.data[DOMAIN] = tibber_connection - async def _close(event): + async def _close(event: Event) -> None: await tibber_connection.rt_disconnect() entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close)) diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index 4e804225c568de3d9415eff96ca0d7228a4b17b4..d0adc0391abf8071f05ea40227cac8ebb8fcd32e 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -1,5 +1,8 @@ """Adds config flow for Tibber integration.""" +from __future__ import annotations + import asyncio +from typing import Any import aiohttp import tibber @@ -7,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries 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 @@ -19,7 +23,9 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" self._async_abort_entries_match() diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index d885bc8fbfd2b99407f6aa41fd44211ff96fa077..5341febc62afdc0528be0598f5618aca75a30a1e 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -3,7 +3,7 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.25.2"], + "requirements": ["pyTibber==0.25.6"], "codeowners": ["@danielhiversen"], "quality_scale": "silver", "config_flow": true, diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index ab912eb33dabb6858061cffebebddea3f91354a3..270528fc4e9ccc8b6a16676ba5fcf5a9d89f5771 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -1,19 +1,29 @@ """Support for Tibber notifications.""" +from __future__ import annotations + import asyncio +from collections.abc import Callable import logging +from typing import Any from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as TIBBER_DOMAIN _LOGGER = logging.getLogger(__name__) -async def async_get_service(hass, config, discovery_info=None): +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> TibberNotificationService: """Get the Tibber notification service.""" tibber_connection = hass.data[TIBBER_DOMAIN] return TibberNotificationService(tibber_connection.send_notification) @@ -22,11 +32,11 @@ async def async_get_service(hass, config, discovery_info=None): class TibberNotificationService(BaseNotificationService): """Implement the notification service for Tibber.""" - def __init__(self, notify): + def __init__(self, notify: Callable) -> None: """Initialize the service.""" self._notify = notify - async def async_send_message(self, message=None, **kwargs): + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to Tibber devices.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) try: diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index ca0c253590f3ff8bbaa1cf7a8a8c111150b733fa..4dcc4a8a7775d63840f047f8c4d64e6f7a3af10a 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -2,11 +2,14 @@ from __future__ import annotations import asyncio +import datetime from datetime import timedelta import logging from random import randrange +from typing import Any import aiohttp +import tibber from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import StatisticData, StatisticMetaData @@ -31,7 +34,7 @@ from homeassistant.const import ( POWER_WATT, SIGNAL_STRENGTH_DECIBELS, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import async_get as async_get_dev_reg from homeassistant.helpers.entity import DeviceInfo, EntityCategory @@ -296,7 +299,9 @@ async def async_setup_entry( class TibberSensor(SensorEntity): """Representation of a generic Tibber sensor.""" - def __init__(self, *args, tibber_home, **kwargs): + def __init__( + self, *args: Any, tibber_home: tibber.TibberHome, **kwargs: Any + ) -> None: """Initialize the sensor.""" super().__init__(*args, **kwargs) self._tibber_home = tibber_home @@ -305,11 +310,11 @@ class TibberSensor(SensorEntity): self._home_name = tibber_home.info["viewer"]["home"]["address"].get( "address1", "" ) - self._device_name = None - self._model = None + self._device_name: None | str = None + self._model: None | str = None @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" device_info = DeviceInfo( identifiers={(TIBBER_DOMAIN, self._tibber_home.home_id)}, @@ -324,10 +329,10 @@ class TibberSensor(SensorEntity): class TibberSensorElPrice(TibberSensor): """Representation of a Tibber sensor for el price.""" - def __init__(self, tibber_home): + def __init__(self, tibber_home: tibber.TibberHome) -> None: """Initialize the sensor.""" super().__init__(tibber_home=tibber_home) - self._last_updated = None + self._last_updated: datetime.datetime | None = None self._spread_load_constant = randrange(5000) self._attr_available = False @@ -380,7 +385,7 @@ class TibberSensorElPrice(TibberSensor): self._attr_native_unit_of_measurement = self._tibber_home.price_unit @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def _fetch_data(self): + async def _fetch_data(self) -> None: _LOGGER.debug("Fetching data") try: await self._tibber_home.update_info_and_price_info() @@ -401,10 +406,10 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity["TibberDataCoordinator"]) def __init__( self, - tibber_home, + tibber_home: tibber.TibberHome, coordinator: TibberDataCoordinator, entity_description: SensorEntityDescription, - ): + ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator, tibber_home=tibber_home) self.entity_description = entity_description @@ -419,7 +424,7 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity["TibberDataCoordinator"]) self._device_name = self._home_name @property - def native_value(self): + def native_value(self) -> Any: """Return the value of the sensor.""" return getattr(self._tibber_home, self.entity_description.key) @@ -429,11 +434,11 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]) def __init__( self, - tibber_home, + tibber_home: tibber.TibberHome, description: SensorEntityDescription, - initial_state, + initial_state: float, coordinator: TibberRtDataCoordinator, - ): + ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator, tibber_home=tibber_home) self.entity_description = description @@ -486,12 +491,17 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]) class TibberRtDataCoordinator(DataUpdateCoordinator): """Handle Tibber realtime data.""" - def __init__(self, async_add_entities, tibber_home, hass): + def __init__( + self, + async_add_entities: AddEntitiesCallback, + tibber_home: tibber.TibberHome, + hass: HomeAssistant, + ) -> None: """Initialize the data handler.""" self._async_add_entities = async_add_entities self._tibber_home = tibber_home self.hass = hass - self._added_sensors = set() + self._added_sensors: set[str] = set() super().__init__( hass, _LOGGER, @@ -506,12 +516,12 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) @callback - def _handle_ha_stop(self, _event) -> None: + def _handle_ha_stop(self, _event: Event) -> None: """Handle Home Assistant stopping.""" self._async_remove_device_updates_handler() @callback - def _add_sensors(self): + def _add_sensors(self) -> None: """Add sensor.""" if not (live_measurement := self.get_live_measurement()): return @@ -534,7 +544,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): if new_entities: self._async_add_entities(new_entities) - def get_live_measurement(self): + def get_live_measurement(self) -> Any: """Get live measurement data.""" if errors := self.data.get("errors"): _LOGGER.error(errors[0]) @@ -545,7 +555,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): class TibberDataCoordinator(DataUpdateCoordinator): """Handle Tibber data and insert statistics.""" - def __init__(self, hass, tibber_connection): + def __init__(self, hass: HomeAssistant, tibber_connection: tibber.Tibber) -> None: """Initialize the data handler.""" super().__init__( hass, @@ -555,13 +565,13 @@ class TibberDataCoordinator(DataUpdateCoordinator): ) self._tibber_connection = tibber_connection - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Update data via API.""" await self._tibber_connection.fetch_consumption_data_active_homes() await self._tibber_connection.fetch_production_data_active_homes() await self._insert_statistics() - async def _insert_statistics(self): + async def _insert_statistics(self) -> None: """Insert Tibber statistics.""" for home in self._tibber_connection.get_homes(): sensors = [] @@ -602,9 +612,10 @@ class TibberDataCoordinator(DataUpdateCoordinator): else home.hourly_consumption_data ) - start = dt_util.parse_datetime(hourly_data[0]["from"]) - timedelta( - hours=1 - ) + from_time = dt_util.parse_datetime(hourly_data[0]["from"]) + if from_time is None: + continue + start = from_time - timedelta(hours=1) stat = await get_instance(self.hass).async_add_executor_job( statistics_during_period, self.hass, @@ -623,15 +634,17 @@ class TibberDataCoordinator(DataUpdateCoordinator): if data.get(sensor_type) is None: continue - start = dt_util.parse_datetime(data["from"]) - if last_stats_time is not None and start <= last_stats_time: + from_time = dt_util.parse_datetime(data["from"]) + if from_time is None or ( + last_stats_time is not None and from_time <= last_stats_time + ): continue _sum += data[sensor_type] statistics.append( StatisticData( - start=start, + start=from_time, state=data[sensor_type], sum=_sum, ) diff --git a/homeassistant/components/tile/manifest.json b/homeassistant/components/tile/manifest.json index dd0e78007f6347dad95572b840125279bcec944f..00b3313c91c07cef0f3118c1393607da835f1545 100644 --- a/homeassistant/components/tile/manifest.json +++ b/homeassistant/components/tile/manifest.json @@ -6,5 +6,6 @@ "requirements": ["pytile==2022.02.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", - "loggers": ["pytile"] + "loggers": ["pytile"], + "integration_type": "hub" } diff --git a/homeassistant/components/tile/translations/bg.json b/homeassistant/components/tile/translations/bg.json index 516ddb3d0151ea278aafe5800bdb9b75a932fbec..08a4edb4db86b707f28df9d2cd3d85a18349526e 100644 --- a/homeassistant/components/tile/translations/bg.json +++ b/homeassistant/components/tile/translations/bg.json @@ -16,7 +16,7 @@ "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "Email" + "username": "\u0418\u043c\u0435\u0439\u043b" } } } diff --git a/homeassistant/components/tile/translations/no.json b/homeassistant/components/tile/translations/no.json index c449beb238243b6f733808d7eae141b2ba4a6060..4d6229b09fbba54f4993a4f325f4021e44117401 100644 --- a/homeassistant/components/tile/translations/no.json +++ b/homeassistant/components/tile/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning" diff --git a/homeassistant/components/tilt_ble/translations/de.json b/homeassistant/components/tilt_ble/translations/de.json index 6b3976336d2966da85992ea3afc74dea6470816a..81dda510bc5cdc1f98d403cb27f5a3fddc3ee062 100644 --- a/homeassistant/components/tilt_ble/translations/de.json +++ b/homeassistant/components/tilt_ble/translations/de.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden" }, "flow_title": "{name}", "step": { "bluetooth_confirm": { - "description": "Willst du {name} einrichten?" + "description": "M\u00f6chtest du {name} einrichten?" }, "user": { "data": { diff --git a/homeassistant/components/tilt_ble/translations/hu.json b/homeassistant/components/tilt_ble/translations/hu.json index 7ef0d3a63013dc9a7c1814fe3c80d99ab7dede60..e1673194c6d885ee4f8bae1faa811bd0f14444f2 100644 --- a/homeassistant/components/tilt_ble/translations/hu.json +++ b/homeassistant/components/tilt_ble/translations/hu.json @@ -14,7 +14,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index 8d6db00b2b9a87685b4519f229e47751aa536dde..cd9453ed430eed5d4cc0c66f7704059fd6b12712 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -9,7 +9,7 @@ from tmb import IBus import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES +from homeassistant.const import CONF_NAME, TIME_MINUTES from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,8 +18,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by Transport Metropolitans de Barcelona" - ICON = "mdi:bus-clock" CONF_APP_ID = "app_id" @@ -75,6 +73,8 @@ def setup_platform( class TMBSensor(SensorEntity): """Implementation of a TMB line/stop Sensor.""" + _attr_attribution = "Data provided by Transport Metropolitans de Barcelona" + def __init__(self, ibus_client, stop, line, name): """Initialize the sensor.""" self._ibus_client = ibus_client @@ -113,7 +113,6 @@ class TMBSensor(SensorEntity): def extra_state_attributes(self): """Return the state attributes of the last update.""" return { - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_BUS_STOP: self._stop, ATTR_LINE: self._line, } diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 956b0901dd7904c52266a8f224aaa0b18fb27c74..e2aaba6cdffbd8cbf20605d2e382bdbc78d97ec3 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -320,6 +320,8 @@ class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator): class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): """Base Tomorrow.io Entity.""" + _attr_attribution = ATTRIBUTION + def __init__( self, config_entry: ConfigEntry, @@ -346,8 +348,3 @@ class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): """ entry_id = self._config_entry.entry_id return self.coordinator.data[entry_id].get(CURRENT, {}).get(property_name) - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/tomorrowio/manifest.json b/homeassistant/components/tomorrowio/manifest.json index 8c097d46eb730d0725bea45e8c0758d12dcc8497..7c3b688f075995865a352fccdb0d75ec5d665600 100644 --- a/homeassistant/components/tomorrowio/manifest.json +++ b/homeassistant/components/tomorrowio/manifest.json @@ -5,5 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/tomorrowio", "requirements": ["pytomorrowio==0.3.5"], "codeowners": ["@raman325", "@lymanepp"], - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "loggers": ["pytomorrowio"], + "integration_type": "service" } diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index b2179cd60f50d21733d263da028400e9be1b14e8..07b922e72ed7e65e5712fd1bb9abf96f9c3102e1 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -20,7 +20,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, @@ -39,6 +38,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import TomorrowioDataUpdateCoordinator, TomorrowioEntity from .const import ( @@ -325,13 +325,10 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): self._attr_unique_id = ( f"{self._config_entry.unique_id}_{slugify(description.name)}" ) - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: self.attribution} if self.entity_description.native_unit_of_measurement is None: - self._attr_native_unit_of_measurement = ( - description.unit_metric - if hass.config.units.is_metric - else description.unit_imperial - ) + self._attr_native_unit_of_measurement = description.unit_metric + if hass.config.units is US_CUSTOMARY_SYSTEM: + self._attr_native_unit_of_measurement = description.unit_imperial @property @abstractmethod @@ -359,7 +356,7 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): desc.imperial_conversion and desc.unit_imperial is not None and desc.unit_imperial != desc.unit_metric - and not self.hass.config.units.is_metric + and self.hass.config.units is US_CUSTOMARY_SYSTEM ): return handle_conversion(state, desc.imperial_conversion) diff --git a/homeassistant/components/tomorrowio/translations/nb.json b/homeassistant/components/tomorrowio/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/tomorrowio/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 371672e184ed3f97dddbd98d03789872423bd87c..9178cc6c01a566286e39487319c85a7786d1a2a5 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -316,6 +316,7 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( icon="mdi:water", entity_registry_enabled_default=False, cls=ToonWaterMeterDeviceSensor, + device_class=SensorDeviceClass.WATER, ), ToonSensorEntityDescription( key="water_daily_usage", @@ -326,6 +327,7 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( icon="mdi:water", entity_registry_enabled_default=False, cls=ToonWaterMeterDeviceSensor, + device_class=SensorDeviceClass.WATER, ), ToonSensorEntityDescription( key="water_meter_reading", @@ -337,6 +339,7 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, cls=ToonWaterMeterDeviceSensor, + device_class=SensorDeviceClass.WATER, ), ToonSensorEntityDescription( key="water_value", diff --git a/homeassistant/components/totalconnect/translations/no.json b/homeassistant/components/totalconnect/translations/no.json index 4ea6b791b239614466f3290327a91faa62e7bce8..a263d3004c1f8629a86acabef7e2ba151609257f 100644 --- a/homeassistant/components/totalconnect/translations/no.json +++ b/homeassistant/components/totalconnect/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Kontoen er allerede konfigurert", "no_locations": "Ingen plasseringer er tilgjengelige for denne brukeren, sjekk TotalConnect-innstillingene", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index dfb873564c5b59aedb8fa029bcddbbf8a8278ec6..a88f96e3fa462dffad3094ef6e40a84e7dfa3304 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -18,6 +18,10 @@ "hostname": "ep*", "macaddress": "E848B8*" }, + { + "hostname": "ep*", + "macaddress": "1C61B4*" + }, { "hostname": "ep*", "macaddress": "003192*" diff --git a/homeassistant/components/trace/websocket_api.py b/homeassistant/components/trace/websocket_api.py index 2ea58db895a5064eba3be3802b4bc3187f25811a..bf5ebfc1031b8a9520e0b20ca8f4584a42a36616 100644 --- a/homeassistant/components/trace/websocket_api.py +++ b/homeassistant/components/trace/websocket_api.py @@ -1,5 +1,6 @@ """Websocket API for automation.""" import json +from typing import Any import voluptuous as vol @@ -54,7 +55,11 @@ def async_setup(hass: HomeAssistant) -> None: } ) @websocket_api.async_response -async def websocket_trace_get(hass, connection, msg): +async def websocket_trace_get( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Get a script or automation trace.""" key = f"{msg['domain']}.{msg['item_id']}" run_id = msg["run_id"] @@ -83,7 +88,11 @@ async def websocket_trace_get(hass, connection, msg): } ) @websocket_api.async_response -async def websocket_trace_list(hass, connection, msg): +async def websocket_trace_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Summarize script and automation traces.""" wanted_domain = msg["domain"] key = f"{msg['domain']}.{msg['item_id']}" if "item_id" in msg else None @@ -102,7 +111,11 @@ async def websocket_trace_list(hass, connection, msg): } ) @websocket_api.async_response -async def websocket_trace_contexts(hass, connection, msg): +async def websocket_trace_contexts( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Retrieve contexts we have traces for.""" key = f"{msg['domain']}.{msg['item_id']}" if "item_id" in msg else None @@ -122,7 +135,11 @@ async def websocket_trace_contexts(hass, connection, msg): vol.Optional("run_id"): str, } ) -def websocket_breakpoint_set(hass, connection, msg): +def websocket_breakpoint_set( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Set breakpoint.""" key = f"{msg['domain']}.{msg['item_id']}" node = msg["node"] @@ -149,7 +166,11 @@ def websocket_breakpoint_set(hass, connection, msg): vol.Optional("run_id"): str, } ) -def websocket_breakpoint_clear(hass, connection, msg): +def websocket_breakpoint_clear( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Clear breakpoint.""" key = f"{msg['domain']}.{msg['item_id']}" node = msg["node"] @@ -163,7 +184,11 @@ def websocket_breakpoint_clear(hass, connection, msg): @callback @websocket_api.require_admin @websocket_api.websocket_command({vol.Required("type"): "trace/debug/breakpoint/list"}) -def websocket_breakpoint_list(hass, connection, msg): +def websocket_breakpoint_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """List breakpoints.""" breakpoints = breakpoint_list(hass) for _breakpoint in breakpoints: @@ -178,7 +203,11 @@ def websocket_breakpoint_list(hass, connection, msg): @websocket_api.websocket_command( {vol.Required("type"): "trace/debug/breakpoint/subscribe"} ) -def websocket_subscribe_breakpoint_events(hass, connection, msg): +def websocket_subscribe_breakpoint_events( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Subscribe to breakpoint events.""" @callback @@ -227,7 +256,11 @@ def websocket_subscribe_breakpoint_events(hass, connection, msg): vol.Required("run_id"): str, } ) -def websocket_debug_continue(hass, connection, msg): +def websocket_debug_continue( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Resume execution of halted script or automation.""" key = f"{msg['domain']}.{msg['item_id']}" run_id = msg["run_id"] @@ -247,7 +280,11 @@ def websocket_debug_continue(hass, connection, msg): vol.Required("run_id"): str, } ) -def websocket_debug_step(hass, connection, msg): +def websocket_debug_step( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Single step a halted script or automation.""" key = f"{msg['domain']}.{msg['item_id']}" run_id = msg["run_id"] @@ -267,7 +304,11 @@ def websocket_debug_step(hass, connection, msg): vol.Required("run_id"): str, } ) -def websocket_debug_stop(hass, connection, msg): +def websocket_debug_stop( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Stop a halted script or automation.""" key = f"{msg['domain']}.{msg['item_id']}" run_id = msg["run_id"] diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json index a672c8629594cef819d61f2be265e3b4dbde7143..308f190a063379393c1c0363f019841d4fb7dd86 100644 --- a/homeassistant/components/tractive/manifest.json +++ b/homeassistant/components/tractive/manifest.json @@ -6,5 +6,6 @@ "requirements": ["aiotractive==0.5.4"], "codeowners": ["@Danielhiversen", "@zhulik", "@bieniu"], "iot_class": "cloud_push", - "loggers": ["aiotractive"] + "loggers": ["aiotractive"], + "integration_type": "device" } diff --git a/homeassistant/components/tractive/translations/bg.json b/homeassistant/components/tractive/translations/bg.json index bd02d32720ab1531463ee40a03031f434c878349..0276f3b11cd3e47cc04d452a39f76fb1bd7b83e1 100644 --- a/homeassistant/components/tractive/translations/bg.json +++ b/homeassistant/components/tractive/translations/bg.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "email": "Email", + "email": "\u0418\u043c\u0435\u0439\u043b", "password": "\u041f\u0430\u0440\u043e\u043b\u0430" } } diff --git a/homeassistant/components/tractive/translations/nb.json b/homeassistant/components/tractive/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/tractive/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/no.json b/homeassistant/components/tractive/translations/no.json index a768b4538488ee4f820e0939e3e7c44bc679fe27..3e5061e027d4b8926ada8af0716a50f924b22d7c 100644 --- a/homeassistant/components/tractive/translations/no.json +++ b/homeassistant/components/tractive/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Enheten er allerede konfigurert", "reauth_failed_existing": "Kunne ikke oppdatere konfigurasjonsoppf\u00f8ringen. Fjern integrasjonen og sett den opp igjen.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index d333473f169ea65bcb73735eeac7157bcdb54418..47b5784296d55abc20d19ab57e7cdceb39cbd0b6 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -2,7 +2,7 @@ "domain": "trafikverket_ferry", "name": "Trafikverket Ferry", "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", - "requirements": ["pytrafikverket==0.2.0.1"], + "requirements": ["pytrafikverket==0.2.1"], "codeowners": ["@gjohansson-ST"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/homeassistant/components/trafikverket_ferry/translations/no.json b/homeassistant/components/trafikverket_ferry/translations/no.json index 4cc6286d78f40b18fbf5061573e080f88aaa437e..bff0bcd45e4b9b1b75c0536729c0071811b1ca03 100644 --- a/homeassistant/components/trafikverket_ferry/translations/no.json +++ b/homeassistant/components/trafikverket_ferry/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 0432670f15c7b6d9a8b36f500c19cfac017b7c5f..d8ccd62f956b37eef8181a396cdf0fd31c7b888d 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -2,7 +2,7 @@ "domain": "trafikverket_train", "name": "Trafikverket Train", "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", - "requirements": ["pytrafikverket==0.2.0.1"], + "requirements": ["pytrafikverket==0.2.1"], "codeowners": ["@endor-force", "@gjohansson-ST"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/homeassistant/components/trafikverket_train/translations/no.json b/homeassistant/components/trafikverket_train/translations/no.json index 12feb2f6abf0264c5f7b13c28ea9dc1326abfb90..37d771628bd6966b53a4c5cdd7f0e1fa64a57a12 100644 --- a/homeassistant/components/trafikverket_train/translations/no.json +++ b/homeassistant/components/trafikverket_train/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index e7efca9b24ae0c8df958fadfe558e107ca90326b..fbe2435f841b517e5c9097a31fe2edc3f83273f4 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -2,7 +2,7 @@ "domain": "trafikverket_weatherstation", "name": "Trafikverket Weather Station", "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", - "requirements": ["pytrafikverket==0.2.0.1"], + "requirements": ["pytrafikverket==0.2.1"], "codeowners": ["@endor-force", "@gjohansson-ST"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/homeassistant/components/trafikverket_weatherstation/translations/bg.json b/homeassistant/components/trafikverket_weatherstation/translations/bg.json index 07c54468c0355e66b7bededb6df780bddf32111a..c4f6c0a2f55991fb795d03b2cb2c19919782b9bd 100644 --- a/homeassistant/components/trafikverket_weatherstation/translations/bg.json +++ b/homeassistant/components/trafikverket_weatherstation/translations/bg.json @@ -5,7 +5,7 @@ }, "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" + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" }, "step": { "user": { diff --git a/homeassistant/components/transmission/translations/no.json b/homeassistant/components/transmission/translations/no.json index fe15e4adc4340defa76516347a984449bd87813d..dfe188f6e3b8e2bdbdbc8fcf128a5f79b161e4aa 100644 --- a/homeassistant/components/transmission/translations/no.json +++ b/homeassistant/components/transmission/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/transmission/translations/ru.json b/homeassistant/components/transmission/translations/ru.json index a01c71898f8d867fd1355f45b29f782fd6faa0bd..ba6787eed7d709c8e8d2394187a99172835de9eb 100644 --- a/homeassistant/components/transmission/translations/ru.json +++ b/homeassistant/components/transmission/translations/ru.json @@ -14,7 +14,7 @@ "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u044c" }, - "description": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f {username}.", + "description": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f {username}.", "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" }, "user": { diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py index 27fd6fd67fd79feb83162814929a4adf38097e37..116dd5c0923ac492eeeea81056c2eb15149b0f09 100644 --- a/homeassistant/components/transport_nsw/sensor.py +++ b/homeassistant/components/transport_nsw/sensor.py @@ -7,13 +7,7 @@ from TransportNSW import TransportNSW import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_MODE, - CONF_API_KEY, - CONF_NAME, - TIME_MINUTES, -) +from homeassistant.const import ATTR_MODE, CONF_API_KEY, CONF_NAME, TIME_MINUTES from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -26,8 +20,6 @@ ATTR_DELAY = "delay" ATTR_REAL_TIME = "real_time" ATTR_DESTINATION = "destination" -ATTRIBUTION = "Data provided by Transport NSW" - CONF_STOP_ID = "stop_id" CONF_ROUTE = "route" CONF_DESTINATION = "destination" @@ -77,6 +69,8 @@ def setup_platform( class TransportNSWSensor(SensorEntity): """Implementation of an Transport NSW sensor.""" + _attr_attribution = "Data provided by Transport NSW" + def __init__(self, data, stop_id, name): """Initialize the sensor.""" self.data = data @@ -107,7 +101,6 @@ class TransportNSWSensor(SensorEntity): ATTR_REAL_TIME: self._times[ATTR_REAL_TIME], ATTR_DESTINATION: self._times[ATTR_DESTINATION], ATTR_MODE: self._times[ATTR_MODE], - ATTR_ATTRIBUTION: ATTRIBUTION, } @property diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index c35391d45739a43cc03fe5d9d91c37e4973b4de0..ab1cd4a6b031136abca7ef9c8ff583b0a45c0bcf 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, @@ -28,8 +27,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Information provided by https://travis-ci.org/" - CONF_BRANCH = "branch" CONF_REPOSITORY = "repository" @@ -142,6 +139,8 @@ def setup_platform( class TravisCISensor(SensorEntity): """Representation of a Travis CI sensor.""" + _attr_attribution = "Information provided by https://travis-ci.org/" + def __init__( self, data, repo_name, user, branch, description: SensorEntityDescription ): @@ -159,7 +158,6 @@ class TravisCISensor(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" attrs = {} - attrs[ATTR_ATTRIBUTION] = ATTRIBUTION if self._build and self._attr_native_value is not None: if self._user and self.entity_description.key == "state": diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 757c33e26530cfe582c05a3193b781aa25452cdf..0e0c41e5e30ef5e8d1aade201488ecabed0de426 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -618,9 +618,9 @@ class SpeechManager: if not tts_file.tags: tts_file.add_tags() if isinstance(tts_file.tags, ID3): - tts_file["artist"] = ID3Text(encoding=3, text=artist) - tts_file["album"] = ID3Text(encoding=3, text=album) - tts_file["title"] = ID3Text(encoding=3, text=message) + tts_file["artist"] = ID3Text(encoding=3, text=artist) # type: ignore[no-untyped-call] + tts_file["album"] = ID3Text(encoding=3, text=album) # type: ignore[no-untyped-call] + tts_file["title"] = ID3Text(encoding=3, text=message) # type: ignore[no-untyped-call] else: tts_file["artist"] = artist tts_file["album"] = album diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json index f3b16cafac5e9e50295c0f152001b04fa5d4d4c3..2957369ff6a63832c7de8112afd58bbb68c8848e 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -2,7 +2,7 @@ "domain": "tts", "name": "Text-to-Speech (TTS)", "documentation": "https://www.home-assistant.io/integrations/tts", - "requirements": ["mutagen==1.45.1"], + "requirements": ["mutagen==1.46.0"], "dependencies": ["http"], "after_dependencies": ["media_player"], "codeowners": ["@pvizeli"], diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 44d370502291b923e22cb9fc913f6942f69f6102..7da59c8cc76ea50eb1769f39cc971693d836e763 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -201,6 +201,15 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Access Control + # https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet + "mk": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CLOSED_OPENED_KIT, + device_class=BinarySensorDeviceClass.LOCK, + on_value={"AQAB"}, + ), + ), # Luminance Sensor # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 "ldcg": ( diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 792036e49ffd134c9c370cc62de169fbf64ad668..50e55cd8d771eb49caf8eb1dd33e83ce0acba47e 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -182,6 +182,7 @@ class DPCode(StrEnum): CLEAN_TIME = "clean_time" CLICK_SUSTAIN_TIME = "click_sustain_time" CLOUD_RECIPE_NUMBER = "cloud_recipe_number" + CLOSED_OPENED_KIT = "closed_opened_kit" CO_STATE = "co_state" CO_STATUS = "co_status" CO_VALUE = "co_value" diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index cf3808819e7547950205f1483652b07aebcd818e..6321bdefc05f50664b53b67823f05e90e5801f6e 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -20,5 +20,6 @@ { "macaddress": "D4A651*" }, { "macaddress": "D81F12*" } ], + "integration_type": "hub", "loggers": ["tuya_iot"] } diff --git a/homeassistant/components/tuya/translations/select.pl.json b/homeassistant/components/tuya/translations/select.pl.json index d75bbd8a8be7592c4b0d740deddabd30d738d80e..109b62a6e345f7c80e0222f3ca3ce41f9ffd5e8d 100644 --- a/homeassistant/components/tuya/translations/select.pl.json +++ b/homeassistant/components/tuya/translations/select.pl.json @@ -1,12 +1,12 @@ { "state": { "tuya__basic_anti_flickr": { - "0": "Wy\u0142\u0105czone", + "0": "wy\u0142\u0105czone", "1": "50 Hz", "2": "60 Hz" }, "tuya__basic_nightvision": { - "0": "Automatycznie", + "0": "automatycznie", "1": "wy\u0142.", "2": "w\u0142." }, @@ -17,19 +17,19 @@ "4h": "4 godziny", "5h": "5 godzin", "6h": "6 godzin", - "cancel": "Anuluj" + "cancel": "anuluj" }, "tuya__curtain_mode": { - "morning": "Ranek", - "night": "Noc" + "morning": "ranek", + "night": "noc" }, "tuya__curtain_motor_mode": { - "back": "Do ty\u0142u", - "forward": "Do przodu" + "back": "do ty\u0142u", + "forward": "do przodu" }, "tuya__decibel_sensitivity": { - "0": "Niska czu\u0142o\u015b\u0107", - "1": "Wysoka czu\u0142o\u015b\u0107" + "0": "niska czu\u0142o\u015b\u0107", + "1": "wysoka czu\u0142o\u015b\u0107" }, "tuya__fan_angle": { "30": "30\u00b0", @@ -37,61 +37,61 @@ "90": "90\u00b0" }, "tuya__fingerbot_mode": { - "click": "Naci\u015bni\u0119cie", - "switch": "Prze\u0142\u0105cznik" + "click": "naci\u015bni\u0119cie", + "switch": "prze\u0142\u0105cznik" }, "tuya__humidifier_level": { - "level_1": "Poziom 1", - "level_10": "Poziom 10", - "level_2": "Poziom 2", - "level_3": "Poziom 3", - "level_4": "Poziom 4", - "level_5": "Poziom 5", - "level_6": "Poziom 6", - "level_7": "Poziom 7", - "level_8": "Poziom 8", - "level_9": "Poziom 9" + "level_1": "poziom 1", + "level_10": "poziom 10", + "level_2": "poziom 2", + "level_3": "poziom 3", + "level_4": "poziom 4", + "level_5": "poziom 5", + "level_6": "poziom 6", + "level_7": "poziom 7", + "level_8": "poziom 8", + "level_9": "poziom 9" }, "tuya__humidifier_moodlighting": { - "1": "Nastr\u00f3j 1", - "2": "Nastr\u00f3j 2", - "3": "Nastr\u00f3j 3", - "4": "Nastr\u00f3j 4", - "5": "Nastr\u00f3j 5" + "1": "nastr\u00f3j 1", + "2": "nastr\u00f3j 2", + "3": "nastr\u00f3j 3", + "4": "nastr\u00f3j 4", + "5": "nastr\u00f3j 5" }, "tuya__humidifier_spray_mode": { - "auto": "Auto", - "health": "Zdrowotny", - "humidity": "Wilgotno\u015b\u0107", - "sleep": "U\u015bpiony", + "auto": "automatyczny", + "health": "zdrowotny", + "humidity": "wilgotno\u015b\u0107", + "sleep": "u\u015bpiony", "work": "Praca" }, "tuya__ipc_work_mode": { - "0": "Tryb niskiego poboru mocy", - "1": "Tryb pracy ci\u0105g\u0142ej" + "0": "tryb niskiego poboru mocy", + "1": "tryb pracy ci\u0105g\u0142ej" }, "tuya__led_type": { - "halogen": "Halogen", - "incandescent": "Jarzeni\u00f3wka", + "halogen": "halogen", + "incandescent": "jarzeni\u00f3wka", "led": "LED" }, "tuya__light_mode": { "none": "wy\u0142.", - "pos": "Wska\u017c lokalizacj\u0119 prze\u0142\u0105cznika", - "relay": "Wska\u017c stan w\u0142./wy\u0142." + "pos": "wska\u017c lokalizacj\u0119 prze\u0142\u0105cznika", + "relay": "wska\u017c stan w\u0142./wy\u0142." }, "tuya__motion_sensitivity": { - "0": "Niska czu\u0142o\u015b\u0107", - "1": "\u015arednia czu\u0142o\u015b\u0107", - "2": "Wysoka czu\u0142o\u015b\u0107" + "0": "niska czu\u0142o\u015b\u0107", + "1": "\u015brednia czu\u0142o\u015b\u0107", + "2": "wysoka czu\u0142o\u015b\u0107" }, "tuya__record_mode": { - "1": "Nagrywaj tylko zdarzenia", - "2": "Nagrywanie ci\u0105g\u0142e" + "1": "nagrywaj tylko zdarzenia", + "2": "nagrywanie ci\u0105g\u0142e" }, "tuya__relay_status": { - "last": "Zapami\u0119taj ostatni stan", - "memory": "Zapami\u0119taj ostatni stan", + "last": "zapami\u0119taj ostatni stan", + "memory": "zapami\u0119taj ostatni stan", "off": "wy\u0142.", "on": "w\u0142.", "power_off": "wy\u0142.", @@ -99,35 +99,35 @@ }, "tuya__vacuum_cistern": { "closed": "zamkni\u0119ta", - "high": "Du\u017ce", - "low": "Ma\u0142e", - "middle": "\u015arednie" + "high": "du\u017ce", + "low": "ma\u0142e", + "middle": "\u015brednie" }, "tuya__vacuum_collection": { - "large": "Du\u017ce", - "middle": "\u015arednie", - "small": "Ma\u0142e" + "large": "du\u017ce", + "middle": "\u015brednie", + "small": "ma\u0142e" }, "tuya__vacuum_mode": { - "bow": "\u0141uk", - "chargego": "Powr\u00f3t do stacji dokuj\u0105cej", - "left_bow": "\u0141uk w lewo", - "left_spiral": "Spirala w lewo", - "mop": "Mop", - "part": "Cz\u0119\u015bciowe", - "partial_bow": "Cz\u0119\u015bciowy \u0142uk", - "pick_zone": "Wybierz stref\u0119", - "point": "Punkt", - "pose": "Pozycja", - "random": "Losowo", - "right_bow": "\u0141uk w prawo", - "right_spiral": "Spirala w prawo", - "single": "Pojedyncze", - "smart": "Smart", - "spiral": "Spirala", - "standby": "Tryb czuwania", - "wall_follow": "Wzd\u0142u\u017c \u015bciany", - "zone": "Strefa" + "bow": "\u0142uk", + "chargego": "powr\u00f3t do stacji dokuj\u0105cej", + "left_bow": "\u0142uk w lewo", + "left_spiral": "spirala w lewo", + "mop": "mop", + "part": "cz\u0119\u015bciowe", + "partial_bow": "cz\u0119\u015bciowy \u0142uk", + "pick_zone": "wybierz stref\u0119", + "point": "punkt", + "pose": "pozycja", + "random": "losowo", + "right_bow": "\u0142uk w prawo", + "right_spiral": "spirala w prawo", + "single": "pojedyncze", + "smart": "smart", + "spiral": "spirala", + "standby": "tryb czuwania", + "wall_follow": "wzd\u0142u\u017c \u015bciany", + "zone": "strefa" } } } \ No newline at end of file diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index c7dd2508fd8b8b34f76c67635f0346c62cde7518..348c99294260fb6e3375e02b9c0b80644c9d461e 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -7,5 +7,6 @@ "codeowners": ["@frenck"], "quality_scale": "platinum", "iot_class": "cloud_polling", + "integration_type": "service", "loggers": ["twentemilieu"] } diff --git a/homeassistant/components/ukraine_alarm/translations/nb.json b/homeassistant/components/ukraine_alarm/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/ukraine_alarm/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ultraloq/__init__.py b/homeassistant/components/ultraloq/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b650c59a5de27820c15003ac3bdc9e301558d370 --- /dev/null +++ b/homeassistant/components/ultraloq/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Ultraloq.""" diff --git a/homeassistant/components/ultraloq/manifest.json b/homeassistant/components/ultraloq/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..4775ba6caa3f5c5f17a8e5b828896e87e58c7cc3 --- /dev/null +++ b/homeassistant/components/ultraloq/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "ultraloq", + "name": "Ultraloq", + "integration_type": "virtual", + "iot_standards": ["zwave"] +} diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index e84e2795a711c23a871653539146640cce24139c..bf0aaef45ddcd5d33957b428e7da79a6b2eee16f 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -42,3 +42,8 @@ DEFAULT_TRACK_WIRED_CLIENTS = True DEFAULT_DETECTION_TIME = 300 ATTR_MANUFACTURER = "Ubiquiti Networks" + +BLOCK_SWITCH = "block" +DPI_SWITCH = "dpi" +POE_SWITCH = "poe" +OUTLET_SWITCH = "outlet" diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 6ad8e41644580b1639ec5e73fb4fd612bdd85207..c421cb5391a8918d5ce906385803fbf2e8854067 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -9,12 +9,7 @@ from typing import Any from aiohttp import CookieJar import aiounifi -from aiounifi.controller import ( - DATA_CLIENT_REMOVED, - DATA_DPI_GROUP, - DATA_DPI_GROUP_REMOVED, - DATA_EVENT, -) +from aiounifi.interfaces.messages import DATA_CLIENT_REMOVED, DATA_EVENT from aiounifi.models.event import EventKey from aiounifi.websocket import WebsocketSignal, WebsocketState import async_timeout @@ -42,6 +37,7 @@ import homeassistant.util.dt as dt_util from .const import ( ATTR_MANUFACTURER, + BLOCK_SWITCH, CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, @@ -66,10 +62,10 @@ from .const import ( DOMAIN as UNIFI_DOMAIN, LOGGER, PLATFORMS, + POE_SWITCH, UNIFI_WIRELESS_CLIENTS, ) from .errors import AuthenticationRequired, CannotConnect -from .switch import BLOCK_SWITCH, POE_SWITCH RETRY_TIMER = 15 CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) @@ -247,14 +243,6 @@ class UniFiController: self.hass, self.signal_remove, data[DATA_CLIENT_REMOVED] ) - elif DATA_DPI_GROUP in data: - async_dispatcher_send(self.hass, self.signal_update) - - elif DATA_DPI_GROUP_REMOVED in data: - async_dispatcher_send( - self.hass, self.signal_remove, data[DATA_DPI_GROUP_REMOVED] - ) - @property def signal_reachable(self) -> str: """Integration specific event to signal a change in connection status.""" diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 2ae7f2c394fb41a2b4fa4590490acbd679421dfc..1c91ea4724b143d742fca80c35bdce0ab20b1872 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging -from aiounifi.interfaces.api_handlers import SOURCE_DATA, SOURCE_EVENT +from aiounifi.models.api import SOURCE_DATA, SOURCE_EVENT from aiounifi.models.event import EventKey from homeassistant.components.device_tracker import DOMAIN, SourceType @@ -111,8 +111,7 @@ def add_client_entities(controller, async_add_entities, clients): trackers.append(UniFiClientTracker(client, controller)) - if trackers: - async_add_entities(trackers) + async_add_entities(trackers) @callback @@ -127,8 +126,7 @@ def add_device_entities(controller, async_add_entities, devices): device = controller.api.devices[mac] trackers.append(UniFiDeviceTracker(device, controller)) - if trackers: - async_add_entities(trackers) + async_add_entities(trackers) class UniFiClientTracker(UniFiClientBase, ScannerEntity): diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 365ce086fb0087da91ed18b8a4286a139f6144ae..5b96560f8c5bf3dbe03bb5427589fd1c8739faa6 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==39"], + "requirements": ["aiounifi==41"], "codeowners": ["@Kane610"], "quality_scale": "platinum", "ssdp": [ @@ -21,5 +21,6 @@ } ], "iot_class": "local_push", + "integration_type": "hub", "loggers": ["aiounifi"] } diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 165507c0498297209c5d3da50aaec16aa3e0d270..ab750f6b33e2c70d77bc9ee4466b563a1d32df87 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -67,8 +67,7 @@ def add_bandwidth_entities(controller, async_add_entities, clients): client = controller.api.clients[mac] sensors.append(sensor_class(client, controller)) - if sensors: - async_add_entities(sensors) + async_add_entities(sensors) @callback @@ -83,8 +82,7 @@ def add_uptime_entities(controller, async_add_entities, clients): client = controller.api.clients[mac] sensors.append(UniFiUpTimeSensor(client, controller)) - if sensors: - async_add_entities(sensors) + async_add_entities(sensors) class UniFiBandwidthSensor(UniFiClient, SensorEntity): diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 72083cdc219f7288dea398fb3ca5c25b28ab3757..65d0041187efc8ee2c104fe48f9fb803e99791ef 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -4,22 +4,28 @@ Support for controlling power supply of clients which are powered over Ethernet Support for controlling network access of clients selected in option flow. Support for controlling deep packet inspection (DPI) restriction groups. """ +from __future__ import annotations import asyncio -from typing import Any - -from aiounifi.interfaces.api_handlers import SOURCE_EVENT +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.interfaces.clients import Clients +from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups +from aiounifi.interfaces.outlets import Outlets +from aiounifi.interfaces.ports import Ports from aiounifi.models.client import ClientBlockRequest from aiounifi.models.device import ( DeviceSetOutletRelayRequest, DeviceSetPoePortModeRequest, ) from aiounifi.models.dpi_restriction_app import DPIRestrictionAppEnableRequest -from aiounifi.models.event import EventKey +from aiounifi.models.event import Event, EventKey from homeassistant.components.switch import DOMAIN, SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import ( @@ -31,18 +37,34 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN +from .const import ( + ATTR_MANUFACTURER, + BLOCK_SWITCH, + DOMAIN as UNIFI_DOMAIN, + DPI_SWITCH, + OUTLET_SWITCH, + POE_SWITCH, +) +from .controller import UniFiController from .unifi_client import UniFiClient -from .unifi_entity_base import UniFiBase - -BLOCK_SWITCH = "block" -DPI_SWITCH = "dpi" -POE_SWITCH = "poe" -OUTLET_SWITCH = "outlet" CLIENT_BLOCKED = (EventKey.WIRED_CLIENT_BLOCKED, EventKey.WIRELESS_CLIENT_BLOCKED) CLIENT_UNBLOCKED = (EventKey.WIRED_CLIENT_UNBLOCKED, EventKey.WIRELESS_CLIENT_UNBLOCKED) +T = TypeVar("T") + + +@dataclass +class UnifiEntityLoader(Generic[T]): + """Validate and load entities from different UniFi handlers.""" + + allowed_fn: Callable[[UniFiController, str], bool] + entity_cls: type[UnifiBlockClientSwitch] | type[UnifiDPIRestrictionSwitch] | type[ + UnifiOutletSwitch + ] | type[UnifiPoePortSwitch] | type[UnifiDPIRestrictionSwitch] + handler_fn: Callable[[UniFiController], T] + supported_fn: Callable[[T, str], bool | None] + async def async_setup_entry( hass: HomeAssistant, @@ -53,7 +75,7 @@ async def async_setup_entry( Switches are controlling network access and switch ports with POE. """ - controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] controller.entities[DOMAIN] = { BLOCK_SWITCH: set(), POE_SWITCH: set(), @@ -89,20 +111,11 @@ async def async_setup_entry( def items_added( clients: set = controller.api.clients, devices: set = controller.api.devices, - dpi_groups: set = controller.api.dpi_groups, ) -> None: """Update the values of the controller.""" - add_outlet_entities(controller, async_add_entities, devices) - - if controller.option_block_clients: - add_block_entities(controller, async_add_entities, clients) - if controller.option_poe_clients: add_poe_entities(controller, async_add_entities, clients, known_poe_clients) - if controller.option_dpi_restrictions: - add_dpi_entities(controller, async_add_entities, dpi_groups) - for signal in (controller.signal_update, controller.signal_options_update): config_entry.async_on_unload( async_dispatcher_connect(hass, signal, items_added) @@ -111,21 +124,34 @@ async def async_setup_entry( items_added() known_poe_clients.clear() + @callback + def async_load_entities(loader: UnifiEntityLoader) -> None: + """Load and subscribe to UniFi devices.""" + entities: list[SwitchEntity] = [] + api_handler = loader.handler_fn(controller) + + @callback + def async_create_entity(event: ItemEvent, obj_id: str) -> None: + """Create UniFi entity.""" + if not loader.allowed_fn(controller, obj_id) or not loader.supported_fn( + api_handler, obj_id + ): + return -@callback -def add_block_entities(controller, async_add_entities, clients): - """Add new switch entities from the controller.""" - switches = [] + entity = loader.entity_cls(obj_id, controller) + if event == ItemEvent.ADDED: + async_add_entities([entity]) + return + entities.append(entity) - for mac in controller.option_block_clients: - if mac in controller.entities[DOMAIN][BLOCK_SWITCH] or mac not in clients: - continue + for obj_id in api_handler: + async_create_entity(ItemEvent.CHANGED, obj_id) + async_add_entities(entities) - client = controller.api.clients[mac] - switches.append(UniFiBlockClientSwitch(client, controller)) + api_handler.subscribe(async_create_entity, ItemEvent.ADDED) - if switches: - async_add_entities(switches) + for unifi_loader in UNIFI_LOADERS: + async_load_entities(unifi_loader) @callback @@ -175,46 +201,7 @@ def add_poe_entities(controller, async_add_entities, clients, known_poe_clients) switches.append(UniFiPOEClientSwitch(client, controller)) - if switches: - async_add_entities(switches) - - -@callback -def add_dpi_entities(controller, async_add_entities, dpi_groups): - """Add new switch entities from the controller.""" - switches = [] - - for group in dpi_groups: - if ( - group in controller.entities[DOMAIN][DPI_SWITCH] - or not dpi_groups[group].dpiapp_ids - ): - continue - - switches.append(UniFiDPIRestrictionSwitch(dpi_groups[group], controller)) - - if switches: - async_add_entities(switches) - - -@callback -def add_outlet_entities(controller, async_add_entities, devices): - """Add new switch entities from the controller.""" - switches = [] - - for mac in devices: - if ( - mac in controller.entities[DOMAIN][OUTLET_SWITCH] - or not (device := controller.api.devices[mac]).outlet_table - ): - continue - - for outlet in device.outlets.values(): - if outlet.has_relay: - switches.append(UniFiOutletSwitch(device, controller, outlet.index)) - - if switches: - async_add_entities(switches) + async_add_entities(switches) class UniFiPOEClientSwitch(UniFiClient, SwitchEntity, RestoreEntity): @@ -314,243 +301,430 @@ class UniFiPOEClientSwitch(UniFiClient, SwitchEntity, RestoreEntity): await self.remove_item({self.client.mac}) -class UniFiBlockClientSwitch(UniFiClient, SwitchEntity): +class UnifiBlockClientSwitch(SwitchEntity): """Representation of a blockable client.""" - DOMAIN = DOMAIN - TYPE = BLOCK_SWITCH - + _attr_device_class = SwitchDeviceClass.SWITCH _attr_entity_category = EntityCategory.CONFIG + _attr_has_entity_name = True + _attr_icon = "mdi:ethernet" + _attr_should_poll = False - def __init__(self, client, controller): + def __init__(self, obj_id: str, controller: UniFiController) -> None: """Set up block switch.""" - super().__init__(client, controller) + controller.entities[DOMAIN][BLOCK_SWITCH].add(obj_id) + self._obj_id = obj_id + self.controller = controller + + self._removed = False + + client = controller.api.clients[obj_id] + self._attr_available = controller.available + self._attr_is_on = not client.blocked + self._attr_unique_id = f"{BLOCK_SWITCH}-{obj_id}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, obj_id)}, + default_manufacturer=client.oui, + default_name=client.name or client.hostname, + ) - self._is_blocked = client.blocked + async def async_added_to_hass(self) -> None: + """Entity created.""" + self.async_on_remove( + self.controller.api.clients.subscribe(self.async_signalling_callback) + ) + self.async_on_remove( + self.controller.api.events.subscribe( + self.async_event_callback, CLIENT_BLOCKED + CLIENT_UNBLOCKED + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, self.controller.signal_remove, self.remove_item + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, self.controller.signal_options_update, self.options_updated + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self.controller.signal_reachable, + self.async_signal_reachable_callback, + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect object when removed.""" + self.controller.entities[DOMAIN][BLOCK_SWITCH].remove(self._obj_id) @callback - def async_update_callback(self) -> None: + def async_signalling_callback(self, event: ItemEvent, obj_id: str) -> None: """Update the clients state.""" - if ( - self.client.last_updated == SOURCE_EVENT - and self.client.event.key in CLIENT_BLOCKED + CLIENT_UNBLOCKED - ): - self._is_blocked = self.client.event.key in CLIENT_BLOCKED + if event == ItemEvent.DELETED: + self.hass.async_create_task(self.remove_item({self._obj_id})) + return - super().async_update_callback() + client = self.controller.api.clients[self._obj_id] + self._attr_is_on = not client.blocked + self._attr_available = self.controller.available + self.async_write_ha_state() - @property - def is_on(self): - """Return true if client is allowed to connect.""" - return not self._is_blocked + @callback + def async_event_callback(self, event: Event) -> None: + """Event subscription callback.""" + if event.mac != self._obj_id: + return + if event.key in CLIENT_BLOCKED + CLIENT_UNBLOCKED: + self._attr_is_on = event.key in CLIENT_UNBLOCKED + self._attr_available = self.controller.available + self.async_write_ha_state() + + @callback + def async_signal_reachable_callback(self) -> None: + """Call when controller connection state change.""" + self.async_signalling_callback(ItemEvent.ADDED, self._obj_id) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on connectivity for client.""" await self.controller.api.request( - ClientBlockRequest.create(self.client.mac, False) + ClientBlockRequest.create(self._obj_id, False) ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off connectivity for client.""" - await self.controller.api.request( - ClientBlockRequest.create(self.client.mac, True) - ) + await self.controller.api.request(ClientBlockRequest.create(self._obj_id, True)) @property def icon(self) -> str: """Return the icon to use in the frontend.""" - if self._is_blocked: + if not self.is_on: return "mdi:network-off" return "mdi:network" async def options_updated(self) -> None: """Config entry options are updated, remove entity if option is disabled.""" - if self.client.mac not in self.controller.option_block_clients: - await self.remove_item({self.client.mac}) + if self._obj_id not in self.controller.option_block_clients: + await self.remove_item({self._obj_id}) + async def remove_item(self, keys: set) -> None: + """Remove entity if key is part of set.""" + if self._obj_id not in keys or self._removed: + return + self._removed = True + if self.registry_entry: + er.async_get(self.hass).async_remove(self.entity_id) + else: + await self.async_remove(force_remove=True) -class UniFiDPIRestrictionSwitch(UniFiBase, SwitchEntity): - """Representation of a DPI restriction group.""" - DOMAIN = DOMAIN - TYPE = DPI_SWITCH +class UnifiDPIRestrictionSwitch(SwitchEntity): + """Representation of a DPI restriction group.""" _attr_entity_category = EntityCategory.CONFIG - def __init__(self, dpi_group, controller): + def __init__(self, obj_id: str, controller: UniFiController) -> None: """Set up dpi switch.""" - super().__init__(dpi_group, controller) + controller.entities[DOMAIN][DPI_SWITCH].add(obj_id) + self._obj_id = obj_id + self.controller = controller - self._is_enabled = self.calculate_enabled() + dpi_group = controller.api.dpi_groups[obj_id] self._known_app_ids = dpi_group.dpiapp_ids - @property - def key(self) -> Any: - """Return item key.""" - return self._item.id + self._attr_available = controller.available + self._attr_is_on = self.calculate_enabled() + self._attr_name = dpi_group.name + self._attr_unique_id = dpi_group.id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"unifi_controller_{obj_id}")}, + manufacturer=ATTR_MANUFACTURER, + model="UniFi Network", + name="UniFi Network", + ) async def async_added_to_hass(self) -> None: """Register callback to known apps.""" - await super().async_added_to_hass() - - apps = self.controller.api.dpi_apps - for app_id in self._item.dpiapp_ids: - apps[app_id].register_callback(self.async_update_callback) + self.async_on_remove( + self.controller.api.dpi_groups.subscribe(self.async_signalling_callback) + ) + self.async_on_remove( + self.controller.api.dpi_apps.subscribe( + self.async_signalling_callback, ItemEvent.CHANGED + ), + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, self.controller.signal_remove, self.remove_item + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, self.controller.signal_options_update, self.options_updated + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self.controller.signal_reachable, + self.async_signal_reachable_callback, + ) + ) async def async_will_remove_from_hass(self) -> None: - """Remove registered callbacks.""" - apps = self.controller.api.dpi_apps - for app_id in self._item.dpiapp_ids: - apps[app_id].remove_callback(self.async_update_callback) - - await super().async_will_remove_from_hass() + """Disconnect object when removed.""" + self.controller.entities[DOMAIN][DPI_SWITCH].remove(self._obj_id) @callback - def async_update_callback(self) -> None: - """Update the DPI switch state. - - Remove entity when no apps are paired with group. - Register callbacks to new apps. - Calculate and update entity state if it has changed. - """ - if not self._item.dpiapp_ids: - self.hass.async_create_task(self.remove_item({self.key})) + def async_signalling_callback(self, event: ItemEvent, obj_id: str) -> None: + """Object has new event.""" + if event == ItemEvent.DELETED: + self.hass.async_create_task(self.remove_item({self._obj_id})) return - if self._known_app_ids != self._item.dpiapp_ids: - self._known_app_ids = self._item.dpiapp_ids - - apps = self.controller.api.dpi_apps - for app_id in self._item.dpiapp_ids: - apps[app_id].register_callback(self.async_update_callback) - - if (enabled := self.calculate_enabled()) != self._is_enabled: - self._is_enabled = enabled - super().async_update_callback() + dpi_group = self.controller.api.dpi_groups[self._obj_id] + if not dpi_group.dpiapp_ids: + self.hass.async_create_task(self.remove_item({self._obj_id})) + return - @property - def unique_id(self): - """Return a unique identifier for this switch.""" - return self._item.id + self._attr_available = self.controller.available + self._attr_is_on = self.calculate_enabled() + self.async_write_ha_state() - @property - def name(self) -> str: - """Return the name of the DPI group.""" - return self._item.name + @callback + def async_signal_reachable_callback(self) -> None: + """Call when controller connection state change.""" + self.async_signalling_callback(ItemEvent.ADDED, self._obj_id) @property def icon(self): """Return the icon to use in the frontend.""" - if self._is_enabled: + if self.is_on: return "mdi:network" return "mdi:network-off" def calculate_enabled(self) -> bool: """Calculate if all apps are enabled.""" + dpi_group = self.controller.api.dpi_groups[self._obj_id] return all( self.controller.api.dpi_apps[app_id].enabled - for app_id in self._item.dpiapp_ids + for app_id in dpi_group.dpiapp_ids if app_id in self.controller.api.dpi_apps ) - @property - def is_on(self): - """Return true if DPI group app restriction is enabled.""" - return self._is_enabled - async def async_turn_on(self, **kwargs: Any) -> None: """Restrict access of apps related to DPI group.""" + dpi_group = self.controller.api.dpi_groups[self._obj_id] return await asyncio.gather( *[ self.controller.api.request( DPIRestrictionAppEnableRequest.create(app_id, True) ) - for app_id in self._item.dpiapp_ids + for app_id in dpi_group.dpiapp_ids ] ) async def async_turn_off(self, **kwargs: Any) -> None: """Remove restriction of apps related to DPI group.""" + dpi_group = self.controller.api.dpi_groups[self._obj_id] return await asyncio.gather( *[ self.controller.api.request( DPIRestrictionAppEnableRequest.create(app_id, False) ) - for app_id in self._item.dpiapp_ids + for app_id in dpi_group.dpiapp_ids ] ) async def options_updated(self) -> None: """Config entry options are updated, remove entity if option is disabled.""" if not self.controller.option_dpi_restrictions: - await self.remove_item({self.key}) + await self.remove_item({self._attr_unique_id}) - @property - def device_info(self) -> DeviceInfo: - """Return a service description for device registry.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, f"unifi_controller_{self._item.site_id}")}, - manufacturer=ATTR_MANUFACTURER, - model="UniFi Network", - name="UniFi Network", - ) + async def remove_item(self, keys: set) -> None: + """Remove entity if key is part of set.""" + if self._attr_unique_id not in keys: + return + if self.registry_entry: + er.async_get(self.hass).async_remove(self.entity_id) + else: + await self.async_remove(force_remove=True) -class UniFiOutletSwitch(UniFiBase, SwitchEntity): - """Representation of a outlet relay.""" - DOMAIN = DOMAIN - TYPE = OUTLET_SWITCH +class UnifiOutletSwitch(SwitchEntity): + """Representation of a outlet relay.""" _attr_device_class = SwitchDeviceClass.OUTLET + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, obj_id: str, controller: UniFiController) -> None: + """Set up UniFi Network entity base.""" + self._device_mac, index = obj_id.split("_", 1) + self._index = int(index) + self._obj_id = obj_id + self.controller = controller + + outlet = self.controller.api.outlets[self._obj_id] + self._attr_name = outlet.name + self._attr_is_on = outlet.relay_state + self._attr_unique_id = f"{self._device_mac}-outlet-{index}" + + device = self.controller.api.devices[self._device_mac] + self._attr_available = controller.available and not device.disabled + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, device.mac)}, + manufacturer=ATTR_MANUFACTURER, + model=device.model, + name=device.name or None, + sw_version=device.version, + hw_version=device.board_revision, + ) - def __init__(self, device, controller, index): - """Set up outlet switch.""" - super().__init__(device, controller) - - self._outlet_index = index - - self._attr_name = f"{device.name or device.model} {device.outlets[index].name}" - self._attr_unique_id = f"{device.mac}-outlet-{index}" + async def async_added_to_hass(self) -> None: + """Entity created.""" + self.async_on_remove( + self.controller.api.outlets.subscribe(self.async_signalling_callback) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self.controller.signal_reachable, + self.async_signal_reachable_callback, + ) + ) - @property - def is_on(self): - """Return true if outlet is active.""" - return self._item.outlets[self._outlet_index].relay_state + @callback + def async_signalling_callback(self, event: ItemEvent, obj_id: str) -> None: + """Object has new event.""" + device = self.controller.api.devices[self._device_mac] + outlet = self.controller.api.outlets[self._obj_id] + self._attr_available = self.controller.available and not device.disabled + self._attr_is_on = outlet.relay_state + self.async_write_ha_state() - @property - def available(self) -> bool: - """Return if switch is available.""" - return not self._item.disabled and self.controller.available + @callback + def async_signal_reachable_callback(self) -> None: + """Call when controller connection state change.""" + self.async_signalling_callback(ItemEvent.ADDED, self._obj_id) async def async_turn_on(self, **kwargs: Any) -> None: """Enable outlet relay.""" + device = self.controller.api.devices[self._device_mac] await self.controller.api.request( - DeviceSetOutletRelayRequest.create(self._item, self._outlet_index, True) + DeviceSetOutletRelayRequest.create(device, self._index, True) ) async def async_turn_off(self, **kwargs: Any) -> None: """Disable outlet relay.""" + device = self.controller.api.devices[self._device_mac] await self.controller.api.request( - DeviceSetOutletRelayRequest.create(self._item, self._outlet_index, False) + DeviceSetOutletRelayRequest.create(device, self._index, False) ) - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._item.mac)}, + +class UnifiPoePortSwitch(SwitchEntity): + """Representation of a Power-over-Ethernet source port on an UniFi device.""" + + _attr_device_class = SwitchDeviceClass.OUTLET + _attr_entity_category = EntityCategory.CONFIG + _attr_entity_registry_enabled_default = False + _attr_has_entity_name = True + _attr_icon = "mdi:ethernet" + _attr_should_poll = False + + def __init__(self, obj_id: str, controller: UniFiController) -> None: + """Set up UniFi Network entity base.""" + self._device_mac, index = obj_id.split("_", 1) + self._index = int(index) + self._obj_id = obj_id + self.controller = controller + + port = self.controller.api.ports[self._obj_id] + self._attr_name = f"{port.name} PoE" + self._attr_is_on = port.poe_mode != "off" + self._attr_unique_id = f"{self._device_mac}-poe-{index}" + + device = self.controller.api.devices[self._device_mac] + self._attr_available = controller.available and not device.disabled + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, device.mac)}, manufacturer=ATTR_MANUFACTURER, - model=self._item.model, - sw_version=self._item.version, - hw_version=self._item.board_revision, + model=device.model, + name=device.name or None, + sw_version=device.version, + hw_version=device.board_revision, + ) + + async def async_added_to_hass(self) -> None: + """Entity created.""" + self.async_on_remove( + self.controller.api.ports.subscribe(self.async_signalling_callback) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self.controller.signal_reachable, + self.async_signal_reachable_callback, + ) ) - if self._item.name: - info[ATTR_NAME] = self._item.name + @callback + def async_signalling_callback(self, event: ItemEvent, obj_id: str) -> None: + """Object has new event.""" + device = self.controller.api.devices[self._device_mac] + port = self.controller.api.ports[self._obj_id] + self._attr_available = self.controller.available and not device.disabled + self._attr_is_on = port.poe_mode != "off" + self.async_write_ha_state() - return info + @callback + def async_signal_reachable_callback(self) -> None: + """Call when controller connection state change.""" + self.async_signalling_callback(ItemEvent.ADDED, self._obj_id) - async def options_updated(self) -> None: - """Config entry options are updated, no options to act on.""" + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable POE for client.""" + device = self.controller.api.devices[self._device_mac] + await self.controller.api.request( + DeviceSetPoePortModeRequest.create(device, self._index, "auto") + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Disable POE for client.""" + device = self.controller.api.devices[self._device_mac] + await self.controller.api.request( + DeviceSetPoePortModeRequest.create(device, self._index, "off") + ) + + +UNIFI_LOADERS: tuple[UnifiEntityLoader, ...] = ( + UnifiEntityLoader[Clients]( + allowed_fn=lambda controller, obj_id: obj_id in controller.option_block_clients, + entity_cls=UnifiBlockClientSwitch, + handler_fn=lambda contrlr: contrlr.api.clients, + supported_fn=lambda handler, obj_id: True, + ), + UnifiEntityLoader[DPIRestrictionGroups]( + allowed_fn=lambda controller, obj_id: controller.option_dpi_restrictions, + entity_cls=UnifiDPIRestrictionSwitch, + handler_fn=lambda controller: controller.api.dpi_groups, + supported_fn=lambda handler, obj_id: bool(handler[obj_id].dpiapp_ids), + ), + UnifiEntityLoader[Outlets]( + allowed_fn=lambda controller, obj_id: True, + entity_cls=UnifiOutletSwitch, + handler_fn=lambda controller: controller.api.outlets, + supported_fn=lambda handler, obj_id: handler[obj_id].has_relay, + ), + UnifiEntityLoader[Ports]( + allowed_fn=lambda controller, obj_id: True, + entity_cls=UnifiPoePortSwitch, + handler_fn=lambda controller: controller.api.ports, + supported_fn=lambda handler, obj_id: handler[obj_id].port_poe, + ), +) diff --git a/homeassistant/components/unifi/translations/no.json b/homeassistant/components/unifi/translations/no.json index 2253bbd50235f0f62bcb9fa2ac5414bd56e8f2ee..339f39ff537a12d1027502c15a6e679472e4fa54 100644 --- a/homeassistant/components/unifi/translations/no.json +++ b/homeassistant/components/unifi/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "UniFi Network-nettstedet er allerede konfigurert", "configuration_updated": "Konfigurasjonen er oppdatert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "faulty_credentials": "Ugyldig godkjenning", diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index ecfbe3549bf02822161d61a6e4405302456a0a4f..5f26cba57cd1ddfefc4bb9c1ce2f6a953adef70f 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -2,8 +2,9 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any +from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.models.device import DeviceUpgradeRequest from homeassistant.components.update import ( @@ -13,7 +14,6 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -21,7 +21,9 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN -from .unifi_entity_base import UniFiBase + +if TYPE_CHECKING: + from .controller import UniFiController LOGGER = logging.getLogger(__name__) @@ -38,104 +40,84 @@ async def async_setup_entry( controller.entities[DOMAIN] = {DEVICE_UPDATE: set()} @callback - def items_added( - clients: set = controller.api.clients, devices: set = controller.api.devices - ) -> None: - """Add device update entities.""" - add_device_update_entities(controller, async_add_entities, devices) - - for signal in (controller.signal_update, controller.signal_options_update): - config_entry.async_on_unload( - async_dispatcher_connect(hass, signal, items_added) - ) - - items_added() - - -@callback -def add_device_update_entities(controller, async_add_entities, devices): - """Add new device update entities from the controller.""" - entities = [] - - for mac in devices: - if mac in controller.entities[DOMAIN][UniFiDeviceUpdateEntity.TYPE]: - continue + def async_add_update_entity(_: ItemEvent, obj_id: str) -> None: + """Add new device update entities from the controller.""" + async_add_entities([UnifiDeviceUpdateEntity(obj_id, controller)]) - device = controller.api.devices[mac] - entities.append(UniFiDeviceUpdateEntity(device, controller)) + controller.api.devices.subscribe(async_add_update_entity, ItemEvent.ADDED) - if entities: - async_add_entities(entities) + for device_id in controller.api.devices: + async_add_update_entity(ItemEvent.ADDED, device_id) -class UniFiDeviceUpdateEntity(UniFiBase, UpdateEntity): +class UnifiDeviceUpdateEntity(UpdateEntity): """Update entity for a UniFi network infrastructure device.""" DOMAIN = DOMAIN TYPE = DEVICE_UPDATE _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_has_entity_name = True - def __init__(self, device, controller): + def __init__(self, obj_id: str, controller: UniFiController) -> None: """Set up device update entity.""" - super().__init__(device, controller) - - self.device = self._item + controller.entities[DOMAIN][DEVICE_UPDATE].add(obj_id) + self.controller = controller + self._obj_id = obj_id + self._attr_unique_id = f"{self.TYPE}-{obj_id}" self._attr_supported_features = UpdateEntityFeature.PROGRESS - - if self.controller.site_role == "admin": + if controller.site_role == "admin": self._attr_supported_features |= UpdateEntityFeature.INSTALL - @property - def name(self) -> str: - """Return the name of the device.""" - return self.device.name or self.device.model - - @property - def unique_id(self) -> str: - """Return a unique identifier for this device.""" - return f"{self.TYPE}-{self.device.mac}" - - @property - def available(self) -> bool: - """Return if controller is available.""" - return not self.device.disabled and self.controller.available - - @property - def in_progress(self) -> bool: - """Update installation in progress.""" - return self.device.state == 4 - - @property - def installed_version(self) -> str | None: - """Version currently in use.""" - return self.device.version - - @property - def latest_version(self) -> str | None: - """Latest version available for install.""" - return self.device.upgrade_to_firmware or self.device.version - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self.device.mac)}, + device = controller.api.devices[obj_id] + self._attr_available = controller.available and not device.disabled + self._attr_in_progress = device.state == 4 + self._attr_installed_version = device.version + self._attr_latest_version = device.upgrade_to_firmware or device.version + + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, obj_id)}, manufacturer=ATTR_MANUFACTURER, - model=self.device.model, - sw_version=self.device.version, + model=device.model, + name=device.name or None, + sw_version=device.version, + hw_version=device.board_revision, + ) + + async def async_added_to_hass(self) -> None: + """Entity created.""" + self.async_on_remove( + self.controller.api.devices.subscribe(self.async_signalling_callback) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self.controller.signal_reachable, + self.async_signal_reachable_callback, + ) ) - if self.device.name: - info[ATTR_NAME] = self.device.name + async def async_will_remove_from_hass(self) -> None: + """Disconnect object when removed.""" + self.controller.entities[DOMAIN][DEVICE_UPDATE].remove(self._obj_id) - return info + @callback + def async_signalling_callback(self, event: ItemEvent, obj_id: str) -> None: + """Object has new event.""" + device = self.controller.api.devices[self._obj_id] + self._attr_available = self.controller.available and not device.disabled + self._attr_in_progress = device.state == 4 + self._attr_installed_version = device.version + self._attr_latest_version = device.upgrade_to_firmware or device.version + self.async_write_ha_state() - async def options_updated(self) -> None: - """No action needed.""" + @callback + def async_signal_reachable_callback(self) -> None: + """Call when controller connection state change.""" + self.async_signalling_callback(ItemEvent.ADDED, self._obj_id) async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - await self.controller.api.request(DeviceUpgradeRequest.create(self.device.mac)) + await self.controller.api.request(DeviceUpgradeRequest.create(self._obj_id)) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index abe881c6294551636f06b1726a3ccc9587806dd0..ae37360c5eeabc83ebe79b98d31290f405a03a71 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==4.2.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.3.4", "unifi-discovery==1.1.7"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/homeassistant/components/upb/translations/nb.json b/homeassistant/components/upb/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/upb/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upcloud/manifest.json b/homeassistant/components/upcloud/manifest.json index 26e1f92ef9ac7caa34e32d73a0e58f93dd66814a..a9e0f74462e0a8f0296e4795d3c733613067aa45 100644 --- a/homeassistant/components/upcloud/manifest.json +++ b/homeassistant/components/upcloud/manifest.json @@ -5,6 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/upcloud", "requirements": ["upcloud-api==2.0.0"], "codeowners": ["@scop"], - "iot_class": "cloud_polling", - "loggers": ["upcloud_api"] + "iot_class": "cloud_polling" } diff --git a/homeassistant/components/update/translations/id.json b/homeassistant/components/update/translations/id.json index c4e6c43ab5cf02414af4c7203c4f7c8fad28a785..23a6690fab4133ea714b903eb503f8e87c31cf31 100644 --- a/homeassistant/components/update/translations/id.json +++ b/homeassistant/components/update/translations/id.json @@ -3,7 +3,7 @@ "trigger_type": { "changed_states": "Ketersediaan pembaruan {entity_name} berubah", "turned_off": "{entity_name} menjadi yang terbaru", - "turned_on": "{entity_name} mendapat pembaruan yang tersedia" + "turned_on": "Tersedia pembaruan untuk {entity_name}" } }, "title": "Versi Baru" diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 95531450e5a519a8cdc657536eb24c01a0aca6f6..0d4c39e6d3df70b718ee3aa2ba35d9783c2aa222 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -1,28 +1,17 @@ -"""Open ports in your router for Home Assistant and provide statistics.""" +"""UPnP/IGD integration.""" from __future__ import annotations import asyncio -from collections.abc import Mapping -from dataclasses import dataclass from datetime import timedelta -from typing import Any -from async_upnp_client.exceptions import UpnpCommunicationError, UpnpConnectionError +from async_upnp_client.exceptions import UpnpConnectionError from homeassistant.components import ssdp -from homeassistant.components.binary_sensor import BinarySensorEntityDescription -from homeassistant.components.sensor import SensorEntityDescription 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, device_registry as dr -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers import config_validation, device_registry from .const import ( CONFIG_ENTRY_HOST, @@ -36,14 +25,15 @@ from .const import ( IDENTIFIER_SERIAL_NUMBER, LOGGER, ) -from .device import Device, async_create_device +from .coordinator import UpnpDataUpdateCoordinator +from .device import async_create_device NOTIFICATION_ID = "upnp_notification" NOTIFICATION_TITLE = "UPnP/IGD Setup" PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +CONFIG_SCHEMA = config_validation.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -126,12 +116,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if device.serial_number: identifiers.add((IDENTIFIER_SERIAL_NUMBER, device.serial_number)) - connections = {(dr.CONNECTION_UPNP, device.udn)} + connections = {(device_registry.CONNECTION_UPNP, device.udn)} if device_mac_address: - connections.add((dr.CONNECTION_NETWORK_MAC, device_mac_address)) + connections.add((device_registry.CONNECTION_NETWORK_MAC, device_mac_address)) - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get_device( + dev_registry = device_registry.async_get(hass) + device_entry = dev_registry.async_get_device( identifiers=identifiers, connections=connections ) if device_entry: @@ -142,7 +132,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) if not device_entry: # No device found, create new device entry. - device_entry = device_registry.async_get_or_create( + device_entry = dev_registry.async_get_or_create( config_entry_id=entry.entry_id, connections=connections, identifiers=identifiers, @@ -155,7 +145,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) else: # Update identifier. - device_entry = device_registry.async_update_device( + device_entry = dev_registry.async_update_device( device_entry.id, new_identifiers=identifiers, ) @@ -191,96 +181,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: del hass.data[DOMAIN][entry.entry_id] return unload_ok - - -@dataclass -class UpnpBinarySensorEntityDescription(BinarySensorEntityDescription): - """A class that describes UPnP entities.""" - - format: str = "s" - unique_id: str | None = None - - -@dataclass -class UpnpSensorEntityDescription(SensorEntityDescription): - """A class that describes a sensor UPnP entities.""" - - format: str = "s" - unique_id: str | None = None - - -class UpnpDataUpdateCoordinator(DataUpdateCoordinator): - """Define an object to update data from UPNP device.""" - - def __init__( - self, - hass: HomeAssistant, - device: Device, - device_entry: dr.DeviceEntry, - update_interval: timedelta, - ) -> None: - """Initialize.""" - self.device = device - self.device_entry = device_entry - - super().__init__( - hass, - LOGGER, - name=device.name, - update_interval=update_interval, - ) - - async def _async_update_data(self) -> Mapping[str, Any]: - """Update data.""" - try: - update_values = await asyncio.gather( - self.device.async_get_traffic_data(), - self.device.async_get_status(), - ) - except UpnpCommunicationError as exception: - LOGGER.debug( - "Caught exception when updating device: %s, exception: %s", - self.device, - exception, - ) - raise UpdateFailed( - f"Unable to communicate with IGD at: {self.device.device_url}" - ) from exception - - return { - **update_values[0], - **update_values[1], - } - - -class UpnpEntity(CoordinatorEntity[UpnpDataUpdateCoordinator]): - """Base class for UPnP/IGD entities.""" - - entity_description: UpnpSensorEntityDescription | UpnpBinarySensorEntityDescription - - def __init__( - self, - coordinator: UpnpDataUpdateCoordinator, - entity_description: UpnpSensorEntityDescription - | UpnpBinarySensorEntityDescription, - ) -> None: - """Initialize the base entities.""" - super().__init__(coordinator) - self._device = coordinator.device - self.entity_description = entity_description - self._attr_name = f"{coordinator.device.name} {entity_description.name}" - self._attr_unique_id = f"{coordinator.device.original_udn}_{entity_description.unique_id or entity_description.key}" - self._attr_device_info = DeviceInfo( - connections=coordinator.device_entry.connections, - name=coordinator.device_entry.name, - manufacturer=coordinator.device_entry.manufacturer, - model=coordinator.device_entry.model, - configuration_url=coordinator.device_entry.configuration_url, - ) - - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and ( - self.coordinator.data.get(self.entity_description.key) is not None - ) diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index 7da7f187882cdc096c5a518341327b0b86fb5c32..7419cc84ea25500e612bb9838ae7f5ff204bcc7b 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -1,19 +1,31 @@ """Support for UPnP/IGD Binary Sensors.""" from __future__ import annotations +from dataclasses import dataclass + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpnpBinarySensorEntityDescription, UpnpDataUpdateCoordinator, UpnpEntity +from . import UpnpDataUpdateCoordinator from .const import DOMAIN, LOGGER, WAN_STATUS +from .entity import UpnpEntity, UpnpEntityDescription + + +@dataclass +class UpnpBinarySensorEntityDescription( + UpnpEntityDescription, BinarySensorEntityDescription +): + """A class that describes binary sensor UPnP entities.""" + -BINARYSENSOR_ENTITY_DESCRIPTIONS: tuple[UpnpBinarySensorEntityDescription, ...] = ( +SENSOR_DESCRIPTIONS: tuple[UpnpBinarySensorEntityDescription, ...] = ( UpnpBinarySensorEntityDescription( key=WAN_STATUS, name="wan status", @@ -29,14 +41,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UPnP/IGD sensors.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: UpnpDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities = [ UpnpStatusBinarySensor( coordinator=coordinator, entity_description=entity_description, ) - for entity_description in BINARYSENSOR_ENTITY_DESCRIPTIONS + for entity_description in SENSOR_DESCRIPTIONS if coordinator.data.get(entity_description.key) is not None ] LOGGER.debug("Adding binary_sensor entities: %s", entities) diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 3386cf407118a392938d2124b9859b1f11e4a453..6b48839846142aa3aa9e33476978724a6ede4728 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -78,7 +78,6 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Paths: # - ssdp(discovery_info) --> ssdp_confirm(None) --> ssdp_confirm({}) --> create_entry() # - user(None): scan --> user({...}) --> create_entry() - # - import(None) --> create_entry() def __init__(self) -> None: """Initialize the UPnP/IGD config flow.""" diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index 023ec82a4876a82fc4e57bdaabf7502cab164998..8d98790983a41c0e89f85e57e50b25bb2837c12c 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -11,13 +11,16 @@ BYTES_RECEIVED = "bytes_received" BYTES_SENT = "bytes_sent" PACKETS_RECEIVED = "packets_received" PACKETS_SENT = "packets_sent" +KIBIBYTES_PER_SEC_RECEIVED = "kibibytes_per_sec_received" +KIBIBYTES_PER_SEC_SENT = "kibibytes_per_sec_sent" +PACKETS_PER_SEC_RECEIVED = "packets_per_sec_received" +PACKETS_PER_SEC_SENT = "packets_per_sec_sent" TIMESTAMP = "timestamp" DATA_PACKETS = "packets" DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{TIME_SECONDS}" WAN_STATUS = "wan_status" ROUTER_IP = "ip" ROUTER_UPTIME = "uptime" -KIBIBYTE = 1024 CONFIG_ENTRY_ST = "st" CONFIG_ENTRY_UDN = "udn" CONFIG_ENTRY_ORIGINAL_UDN = "original_udn" diff --git a/homeassistant/components/upnp/coordinator.py b/homeassistant/components/upnp/coordinator.py new file mode 100644 index 0000000000000000000000000000000000000000..18d37b4a3886e240451659794850d63dcc17dc18 --- /dev/null +++ b/homeassistant/components/upnp/coordinator.py @@ -0,0 +1,50 @@ +"""UPnP/IGD coordinator.""" + +from collections.abc import Mapping +from datetime import timedelta +from typing import Any + +from async_upnp_client.exceptions import UpnpCommunicationError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER +from .device import Device + + +class UpnpDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to update data from UPNP device.""" + + def __init__( + self, + hass: HomeAssistant, + device: Device, + device_entry: DeviceEntry, + update_interval: timedelta, + ) -> None: + """Initialize.""" + self.device = device + self.device_entry = device_entry + + super().__init__( + hass, + LOGGER, + name=device.name, + update_interval=update_interval, + ) + + async def _async_update_data(self) -> Mapping[str, Any]: + """Update data.""" + try: + return await self.device.async_get_data() + except UpnpCommunicationError as exception: + LOGGER.debug( + "Caught exception when updating device: %s, exception: %s", + self.device, + exception, + ) + raise UpdateFailed( + f"Unable to communicate with IGD at: {self.device.device_url}" + ) from exception diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index e06ada02b77705cc1ce1fc5ccd15403fe86fb67d..61784749c6f39d32e363bdaf9cce30829a6c5e7b 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -1,7 +1,6 @@ """Home Assistant representation of an UPnP/IGD.""" from __future__ import annotations -import asyncio from collections.abc import Mapping from functools import partial from ipaddress import ip_address @@ -10,19 +9,21 @@ from urllib.parse import urlparse from async_upnp_client.aiohttp import AiohttpSessionRequester from async_upnp_client.client_factory import UpnpFactory -from async_upnp_client.exceptions import UpnpError -from async_upnp_client.profiles.igd import IgdDevice, StatusInfo +from async_upnp_client.profiles.igd import IgdDevice from getmac import get_mac_address from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util.dt import utcnow from .const import ( BYTES_RECEIVED, BYTES_SENT, + KIBIBYTES_PER_SEC_RECEIVED, + KIBIBYTES_PER_SEC_SENT, LOGGER as _LOGGER, + PACKETS_PER_SEC_RECEIVED, + PACKETS_PER_SEC_SENT, PACKETS_RECEIVED, PACKETS_SENT, ROUTER_IP, @@ -51,7 +52,7 @@ async def async_create_device(hass: HomeAssistant, ssdp_location: str) -> Device session = async_get_clientsession(hass, verify_ssl=False) requester = AiohttpSessionRequester(session, with_sleep=True, timeout=20) - factory = UpnpFactory(requester, disable_state_variable_validation=True) + factory = UpnpFactory(requester, non_strict=True) upnp_device = await factory.async_create_device(ssdp_location) # Create profile wrapper. @@ -134,69 +135,35 @@ class Device: """Get string representation.""" return f"IGD Device: {self.name}/{self.udn}::{self.device_type}" - async def async_get_traffic_data(self) -> Mapping[str, Any]: - """ - Get all traffic data in one go. - - Traffic data consists of: - - total bytes sent - - total bytes received - - total packets sent - - total packats received - - Data is timestamped. - """ - _LOGGER.debug("Getting traffic statistics from device: %s", self) - - values = await asyncio.gather( - self._igd_device.async_get_total_bytes_received(), - self._igd_device.async_get_total_bytes_sent(), - self._igd_device.async_get_total_packets_received(), - self._igd_device.async_get_total_packets_sent(), - ) + async def async_get_data(self) -> Mapping[str, Any]: + """Get all data from device.""" + _LOGGER.debug("Getting data for device: %s", self) + igd_state = await self._igd_device.async_get_traffic_and_status_data() + status_info = igd_state.status_info + if status_info is not None and not isinstance(status_info, Exception): + wan_status = status_info.connection_status + router_uptime = status_info.uptime + else: + wan_status = None + router_uptime = None - return { - TIMESTAMP: utcnow(), - BYTES_RECEIVED: values[0], - BYTES_SENT: values[1], - PACKETS_RECEIVED: values[2], - PACKETS_SENT: values[3], - } + def get_value(value: Any) -> Any: + if value is None or isinstance(value, Exception): + return None - async def async_get_status(self) -> Mapping[str, Any]: - """Get connection status, uptime, and external IP.""" - _LOGGER.debug("Getting status for device: %s", self) - - values = await asyncio.gather( - self._igd_device.async_get_status_info(), - self._igd_device.async_get_external_ip_address(), - return_exceptions=True, - ) - status_info: StatusInfo | None = None - router_ip: str | None = None - - for idx, value in enumerate(values): - if isinstance(value, UpnpError): - # Not all routers support some of these items although based - # on defined standard they should. - _LOGGER.debug( - "Exception occurred while trying to get status %s for device %s: %s", - "status" if idx == 1 else "external IP address", - self, - str(value), - ) - continue - - if isinstance(value, Exception): - raise value - - if isinstance(value, StatusInfo): - status_info = value - elif isinstance(value, str): - router_ip = value + return value return { - WAN_STATUS: status_info[0] if status_info is not None else None, - ROUTER_UPTIME: status_info[2] if status_info is not None else None, - ROUTER_IP: router_ip, + TIMESTAMP: igd_state.timestamp, + BYTES_RECEIVED: get_value(igd_state.bytes_received), + BYTES_SENT: get_value(igd_state.bytes_sent), + PACKETS_RECEIVED: get_value(igd_state.packets_received), + PACKETS_SENT: get_value(igd_state.packets_sent), + WAN_STATUS: wan_status, + ROUTER_UPTIME: router_uptime, + ROUTER_IP: get_value(igd_state.external_ip_address), + KIBIBYTES_PER_SEC_RECEIVED: igd_state.kibibytes_per_sec_received, + KIBIBYTES_PER_SEC_SENT: igd_state.kibibytes_per_sec_sent, + PACKETS_PER_SEC_RECEIVED: igd_state.packets_per_sec_received, + PACKETS_PER_SEC_SENT: igd_state.packets_per_sec_sent, } diff --git a/homeassistant/components/upnp/entity.py b/homeassistant/components/upnp/entity.py new file mode 100644 index 0000000000000000000000000000000000000000..b787018adcc9458752e036176d4744bff288336b --- /dev/null +++ b/homeassistant/components/upnp/entity.py @@ -0,0 +1,54 @@ +"""Entity for UPnP/IGD.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import UpnpDataUpdateCoordinator + + +@dataclass +class UpnpEntityDescription(EntityDescription): + """UPnP entity description.""" + + format: str = "s" + unique_id: str | None = None + value_key: str | None = None + + def __post_init__(self): + """Post initialize.""" + self.value_key = self.value_key or self.key + + +class UpnpEntity(CoordinatorEntity[UpnpDataUpdateCoordinator]): + """Base class for UPnP/IGD entities.""" + + entity_description: UpnpEntityDescription + + def __init__( + self, + coordinator: UpnpDataUpdateCoordinator, + entity_description: UpnpEntityDescription, + ) -> None: + """Initialize the base entities.""" + super().__init__(coordinator) + self._device = coordinator.device + self.entity_description = entity_description + self._attr_name = f"{coordinator.device.name} {entity_description.name}" + self._attr_unique_id = f"{coordinator.device.original_udn}_{entity_description.unique_id or entity_description.key}" + self._attr_device_info = DeviceInfo( + connections=coordinator.device_entry.connections, + name=coordinator.device_entry.name, + manufacturer=coordinator.device_entry.manufacturer, + model=coordinator.device_entry.model, + configuration_url=coordinator.device_entry.configuration_url, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and ( + self.coordinator.data.get(self.entity_description.key) is not None + ) diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index a4b913ec4c8a3422f3636583c105e1394e1313e8..9b4151c35c5180544f1ca135c36f9b941a54e473 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,9 +3,9 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.31.2", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.32.1", "getmac==0.8.2"], "dependencies": ["network", "ssdp"], - "codeowners": ["@StevenLooman", "@ehendrix23"], + "codeowners": ["@StevenLooman"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" @@ -15,5 +15,6 @@ } ], "iot_class": "local_polling", - "loggers": ["async_upnp_client"] + "loggers": ["async_upnp_client"], + "integration_type": "device" } diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 53a918ba053a4fe813e76b1c4744f6ba5316566b..3d0c71fafdb23b4893753f8fb4545b50761c8335 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,31 +1,46 @@ """Support for UPnP/IGD Sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND, TIME_SECONDS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpnpDataUpdateCoordinator, UpnpEntity, UpnpSensorEntityDescription from .const import ( BYTES_RECEIVED, BYTES_SENT, DATA_PACKETS, DATA_RATE_PACKETS_PER_SECOND, DOMAIN, - KIBIBYTE, + KIBIBYTES_PER_SEC_RECEIVED, + KIBIBYTES_PER_SEC_SENT, LOGGER, + PACKETS_PER_SEC_RECEIVED, + PACKETS_PER_SEC_SENT, PACKETS_RECEIVED, PACKETS_SENT, ROUTER_IP, ROUTER_UPTIME, - TIMESTAMP, WAN_STATUS, ) +from .coordinator import UpnpDataUpdateCoordinator +from .entity import UpnpEntity, UpnpEntityDescription + -RAW_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( +@dataclass +class UpnpSensorEntityDescription(UpnpEntityDescription, SensorEntityDescription): + """A class that describes a sensor UPnP entities.""" + + +SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( UpnpSensorEntityDescription( key=BYTES_RECEIVED, name=f"{DATA_BYTES} received", @@ -33,6 +48,7 @@ RAW_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( native_unit_of_measurement=DATA_BYTES, format="d", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, ), UpnpSensorEntityDescription( key=BYTES_SENT, @@ -41,6 +57,7 @@ RAW_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( native_unit_of_measurement=DATA_BYTES, format="d", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, @@ -49,6 +66,7 @@ RAW_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( native_unit_of_measurement=DATA_PACKETS, format="d", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, ), UpnpSensorEntityDescription( key=PACKETS_SENT, @@ -57,11 +75,13 @@ RAW_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( native_unit_of_measurement=DATA_PACKETS, format="d", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, ), UpnpSensorEntityDescription( key=ROUTER_IP, name="External IP", icon="mdi:server-network", + entity_category=EntityCategory.DIAGNOSTIC, ), UpnpSensorEntityDescription( key=ROUTER_UPTIME, @@ -79,42 +99,47 @@ RAW_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), -) - -DERIVED_SENSORS: tuple[UpnpSensorEntityDescription, ...] = ( UpnpSensorEntityDescription( key=BYTES_RECEIVED, + value_key=KIBIBYTES_PER_SEC_RECEIVED, unique_id="KiB/sec_received", name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} received", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, format=".1f", + state_class=SensorStateClass.MEASUREMENT, ), UpnpSensorEntityDescription( key=BYTES_SENT, + value_key=KIBIBYTES_PER_SEC_SENT, unique_id="KiB/sec_sent", name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} sent", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND, format=".1f", + state_class=SensorStateClass.MEASUREMENT, ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, + value_key=PACKETS_PER_SEC_RECEIVED, unique_id="packets/sec_received", name=f"{DATA_RATE_PACKETS_PER_SECOND} received", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, format=".1f", entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), UpnpSensorEntityDescription( key=PACKETS_SENT, + value_key=PACKETS_PER_SEC_SENT, unique_id="packets/sec_sent", name=f"{DATA_RATE_PACKETS_PER_SECOND} sent", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, format=".1f", entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, ), ) @@ -125,26 +150,16 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UPnP/IGD sensors.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: UpnpDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[UpnpSensor] = [ - RawUpnpSensor( + UpnpSensor( coordinator=coordinator, entity_description=entity_description, ) - for entity_description in RAW_SENSORS + for entity_description in SENSOR_DESCRIPTIONS if coordinator.data.get(entity_description.key) is not None ] - entities.extend( - [ - DerivedUpnpSensor( - coordinator=coordinator, - entity_description=entity_description, - ) - for entity_description in DERIVED_SENSORS - if coordinator.data.get(entity_description.key) is not None - ] - ) LOGGER.debug("Adding sensor entities: %s", entities) async_add_entities(entities) @@ -155,64 +170,10 @@ class UpnpSensor(UpnpEntity, SensorEntity): entity_description: UpnpSensorEntityDescription - -class RawUpnpSensor(UpnpSensor): - """Representation of a UPnP/IGD sensor.""" - @property def native_value(self) -> str | None: """Return the state of the device.""" - value = self.coordinator.data[self.entity_description.key] + value = self.coordinator.data[self.entity_description.value_key] if value is None: return None return format(value, self.entity_description.format) - - -class DerivedUpnpSensor(UpnpSensor): - """Representation of a UNIT Sent/Received per second sensor.""" - - def __init__( - self, - coordinator: UpnpDataUpdateCoordinator, - entity_description: UpnpSensorEntityDescription, - ) -> None: - """Initialize sensor.""" - super().__init__(coordinator=coordinator, entity_description=entity_description) - self._last_value = None - self._last_timestamp = None - - def _has_overflowed(self, current_value) -> bool: - """Check if value has overflowed.""" - return current_value < self._last_value - - @property - def native_value(self) -> str | None: - """Return the state of the device.""" - # Can't calculate any derivative if we have only one value. - current_value = self.coordinator.data[self.entity_description.key] - if current_value is None: - return None - current_timestamp = self.coordinator.data[TIMESTAMP] - if self._last_value is None or self._has_overflowed(current_value): - self._last_value = current_value - self._last_timestamp = current_timestamp - return None - - # Calculate derivative. - delta_value = current_value - self._last_value - if ( - self.entity_description.native_unit_of_measurement - == DATA_RATE_KIBIBYTES_PER_SECOND - ): - delta_value /= KIBIBYTE - delta_time = current_timestamp - self._last_timestamp - if delta_time.total_seconds() == 0: - # Prevent division by 0. - return None - derived = delta_value / delta_time.total_seconds() - - # Store current values for future use. - self._last_value = current_value - self._last_timestamp = current_timestamp - - return format(derived, self.entity_description.format) diff --git a/homeassistant/components/upnp/translations/tr.json b/homeassistant/components/upnp/translations/tr.json index 4742549eaff71f62e11bbd25d446d5a369772f7b..7d24215a47e407b17aa8417ec5a726a3cf61e25e 100644 --- a/homeassistant/components/upnp/translations/tr.json +++ b/homeassistant/components/upnp/translations/tr.json @@ -13,7 +13,7 @@ "step": { "init": { "one": "Bo\u015f", - "other": "" + "other": "Bo\u015f" }, "ssdp_confirm": { "description": "Bu UPnP / IGD cihaz\u0131n\u0131 kurmak istiyor musunuz?" diff --git a/homeassistant/components/uprise_smart_shades/manifest.json b/homeassistant/components/uprise_smart_shades/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..a0ddc2bfb2f83d656f454984e2c25d4a0ad1e743 --- /dev/null +++ b/homeassistant/components/uprise_smart_shades/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "uprise_smart_shades", + "name": "Uprise Smart Shades", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/uptime/manifest.json b/homeassistant/components/uptime/manifest.json index 3bcc47815f8262d72e5cfe7ac5cd0e89088dcf9d..ef81472569923a536c6e6906ddf6499568c49569 100644 --- a/homeassistant/components/uptime/manifest.json +++ b/homeassistant/components/uptime/manifest.json @@ -5,5 +5,6 @@ "codeowners": ["@frenck"], "quality_scale": "internal", "iot_class": "local_push", + "integration_type": "service", "config_flow": true } diff --git a/homeassistant/components/uptime/translations/ca.json b/homeassistant/components/uptime/translations/ca.json index bbd6caebc119c7b4e470b016abaf7b274384db25..ef0b636dcf7c052661ca0337fc2838c22c921d52 100644 --- a/homeassistant/components/uptime/translations/ca.json +++ b/homeassistant/components/uptime/translations/ca.json @@ -11,8 +11,9 @@ }, "issues": { "removed_yaml": { - "title": "La configuraci\u00f3 YAML d'Uptime s'ha eliminat" + "description": "La configuraci\u00f3 de data i temps d'engegada mitjan\u00e7ant YAML s'ha eliminat.\n\nHome Assistant ja no utilitza la configuraci\u00f3 YAML existent.\n\nElimina la configuraci\u00f3 YAML corresponent del fitxer configuration.yaml i reinicia Home Assistant per solucionar aquest problema.", + "title": "La configuraci\u00f3 YAML de data i temps d'engegada s'ha eliminat" } }, - "title": "Temps en funcionament" + "title": "Data i temps d'engegada" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/el.json b/homeassistant/components/uptime/translations/el.json index d70141f21738685228690387e61e6a896e2d967a..7e338844e9ddbac87c7b176497e2fc564efb39f5 100644 --- a/homeassistant/components/uptime/translations/el.json +++ b/homeassistant/components/uptime/translations/el.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "\u0397 \u03c1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7 \u03c4\u03bf\u03c5 Uptime \u03bc\u03b5 \u03c7\u03c1\u03ae\u03c3\u03b7 YAML \u03ad\u03c7\u03b5\u03b9 \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03b7\u03b8\u03b5\u03af.\n\n\u0397 \u03c5\u03c0\u03ac\u03c1\u03c7\u03bf\u03c5\u03c3\u03b1 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 YAML \u03b4\u03b5\u03bd \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03bf Home Assistant.\n\n\u0391\u03c6\u03b1\u03b9\u03c1\u03ad\u03c3\u03c4\u03b5 \u03c4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 YAML \u03b1\u03c0\u03cc \u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf configuration.yaml \u03ba\u03b1\u03b9 \u03b5\u03c0\u03b1\u03bd\u03b5\u03ba\u03ba\u03b9\u03bd\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf Home Assistant \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03bf\u03c1\u03b8\u03ce\u03c3\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u03c0\u03c1\u03cc\u03b2\u03bb\u03b7\u03bc\u03b1.", + "title": "\u0397 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 YAML \u03c4\u03bf\u03c5 Uptime \u03ad\u03c7\u03b5\u03b9 \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03b7\u03b8\u03b5\u03af" + } + }, "title": "\u03a7\u03c1\u03cc\u03bd\u03bf\u03c2 \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/et.json b/homeassistant/components/uptime/translations/et.json index f1b40328dab2c590d44dbfd19ee4cb9c67b43e27..71ef4a5c265436c968eb8b2f32dc80b5ca421bcf 100644 --- a/homeassistant/components/uptime/translations/et.json +++ b/homeassistant/components/uptime/translations/et.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Uptime konfigureerimine YAML-i abil on eemaldatud. \n\n Koduassistent ei kasuta teie olemasolevat YAML-i konfiguratsiooni. \n\n Selle probleemi lahendamiseks eemaldage YAML-i konfiguratsioon failist configuration.yaml ja taask\u00e4ivitage Home Assistant.", + "title": "Uptime YAML-i konfiguratsioon on eemaldatud" + } + }, "title": "T\u00f6\u00f6aeg" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/hu.json b/homeassistant/components/uptime/translations/hu.json index 241c28b48eadee767fc3e2a35c83a2d457595b8c..bae1e5edabc2188adac5830f127eb0037c647e1b 100644 --- a/homeassistant/components/uptime/translations/hu.json +++ b/homeassistant/components/uptime/translations/hu.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Az Uptime YAML haszn\u00e1lat\u00e1val t\u00f6rt\u00e9n\u0151 konfigur\u00e1l\u00e1sa elt\u00e1vol\u00edt\u00e1sra ker\u00fclt.\n\nA megl\u00e9v\u0151 YAML konfigur\u00e1ci\u00f3t a Home Assistant nem haszn\u00e1lja.\n\nA probl\u00e9ma megold\u00e1s\u00e1hoz t\u00e1vol\u00edtsa el a YAML konfigur\u00e1ci\u00f3t a configuration.yaml f\u00e1jlb\u00f3l, \u00e9s ind\u00edtsa \u00fajra a Home Assistantot.", + "title": "Az Uptime YAML konfigur\u00e1ci\u00f3 elt\u00e1vol\u00edt\u00e1sra ker\u00fclt" + } + }, "title": "Uptime" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/id.json b/homeassistant/components/uptime/translations/id.json index bf6ea606f2b96b181dcae7e5a38a8bc9e708e408..33b92602016eeba5a53c10c27d21e6f59d0d7f4b 100644 --- a/homeassistant/components/uptime/translations/id.json +++ b/homeassistant/components/uptime/translations/id.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Proses konfigurasi Integrasi Uptime lewat YAML telah dihapus.\n\nKonfigurasi YAML yang ada tidak digunakan oleh Home Assistant.\n\nHapus konfigurasi YAML dari file configuration.yaml dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Uptime telah dihapus" + } + }, "title": "Uptime" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/it.json b/homeassistant/components/uptime/translations/it.json index 9913180a3094c1a2e311615783c5f1fd585b31ea..f842f6d6aa91663a7f2f7fc4445cf4259810b943 100644 --- a/homeassistant/components/uptime/translations/it.json +++ b/homeassistant/components/uptime/translations/it.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "La configurazione di Uptime tramite YAML \u00e8 stata rimossa. \n\nLa tua configurazione YAML esistente non viene utilizzata da Home Assistant. \n\nRimuovere la configurazione YAML dal file configuration.yaml e riavviare Home Assistant per risolvere questo problema.", + "title": "La configurazione YAML di Uptime \u00e8 stata rimossa" + } + }, "title": "Tempo di funzionamento" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/nb.json b/homeassistant/components/uptime/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..13c4d0e9385efa600e027a54bc4f995c792623c5 --- /dev/null +++ b/homeassistant/components/uptime/translations/nb.json @@ -0,0 +1,3 @@ +{ + "title": "Oppetid" +} \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/nl.json b/homeassistant/components/uptime/translations/nl.json index b4ed0a1db36b784be02dadebecee2336ec4354bd..bd054cfefc70e057e7431b0879bd67fe08101665 100644 --- a/homeassistant/components/uptime/translations/nl.json +++ b/homeassistant/components/uptime/translations/nl.json @@ -9,5 +9,10 @@ } } }, + "issues": { + "removed_yaml": { + "title": "De Uptime YAML-configuratie is verwijderd" + } + }, "title": "Uptime" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/no.json b/homeassistant/components/uptime/translations/no.json index 9ac16f0a20c229da5e7cc36c817e8b995cb6a43b..fa9103dff3cd88ceefbd697b95a1720de0b9741a 100644 --- a/homeassistant/components/uptime/translations/no.json +++ b/homeassistant/components/uptime/translations/no.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Konfigurering av Oppetid med YAML er fjernet. \n\n Din eksisterende YAML-konfigurasjon brukes ikke av Home Assistant. \n\n Fjern YAML-konfigurasjonen fra configuration.yaml-filen og start Home Assistant p\u00e5 nytt for \u00e5 fikse dette problemet.", + "title": "Oppetid YAML-konfigurasjonen er fjernet" + } + }, "title": "Oppetid" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/pl.json b/homeassistant/components/uptime/translations/pl.json index bca14b2f14c08de5768d9fb1cfc2cc5b2693cf33..2e5f40c3bd7dd529b8540787ca7f43a776f8382a 100644 --- a/homeassistant/components/uptime/translations/pl.json +++ b/homeassistant/components/uptime/translations/pl.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Konfiguracja Uptime za pomoc\u0105 YAML zosta\u0142a usuni\u0119ta. \n\nTwoja istniej\u0105ca konfiguracja YAML nie jest u\u017cywana przez Home Assistant. \n\nUsu\u0144 konfiguracj\u0119 YAML z pliku configuration.yaml i uruchom ponownie Home Assistanta, aby rozwi\u0105za\u0107 ten problem.", + "title": "Konfiguracja YAML dla Uptime zosta\u0142a usuni\u0119ta" + } + }, "title": "Uptime" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/pt-BR.json b/homeassistant/components/uptime/translations/pt-BR.json index d3dddae8233c8492be82490ae55f3d9a5bb33533..ae89c128e0863049e6564c7d4c7cacdf7d99af44 100644 --- a/homeassistant/components/uptime/translations/pt-BR.json +++ b/homeassistant/components/uptime/translations/pt-BR.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "A configura\u00e7\u00e3o do Uptime usando YAML foi removida. \n\n Sua configura\u00e7\u00e3o YAML existente n\u00e3o \u00e9 usada pelo Home Assistant. \n\n Remova a configura\u00e7\u00e3o YAML do arquivo configuration.yaml e reinicie o Home Assistant para corrigir esse problema.", + "title": "A configura\u00e7\u00e3o YAML do Uptime foi removida" + } + }, "title": "Tempo de atividade" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/sv.json b/homeassistant/components/uptime/translations/sv.json index 0d9e03ec5750950f3887cbbb98aabaddd847e1e8..48ca71741a52e92015dd3d312274d93617ae32c6 100644 --- a/homeassistant/components/uptime/translations/sv.json +++ b/homeassistant/components/uptime/translations/sv.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "Konfigurering av Uptime med YAML har tagits bort. \n\n Din befintliga YAML-konfiguration anv\u00e4nds inte av Home Assistant. \n\n Ta bort YAML-konfigurationen fr\u00e5n filen configuration.yaml och starta om Home Assistant f\u00f6r att \u00e5tg\u00e4rda problemet.", + "title": "Uptime YAML-konfigurationen har tagits bort" + } + }, "title": "Upptid" } \ No newline at end of file diff --git a/homeassistant/components/uptime/translations/tr.json b/homeassistant/components/uptime/translations/tr.json index ed090a383984b5f6ddab8252f832882e35ec8654..72132e5e97924cf64e23abcaef7b0e97b20ce228 100644 --- a/homeassistant/components/uptime/translations/tr.json +++ b/homeassistant/components/uptime/translations/tr.json @@ -9,5 +9,11 @@ } } }, + "issues": { + "removed_yaml": { + "description": "YAML kullanarak \u00c7al\u0131\u015fma S\u00fcresini yap\u0131land\u0131rma kald\u0131r\u0131ld\u0131. \n\n Mevcut YAML yap\u0131land\u0131rman\u0131z Home Assistant taraf\u0131ndan kullan\u0131lm\u0131yor. \n\n YAML yap\u0131land\u0131rmas\u0131n\u0131 configuration.yaml dosyan\u0131zdan kald\u0131r\u0131n ve bu sorunu gidermek i\u00e7in Home Assistant'\u0131 yeniden ba\u015flat\u0131n.", + "title": "Uptime YAML yap\u0131land\u0131rmas\u0131 kald\u0131r\u0131ld\u0131" + } + }, "title": "\u00c7al\u0131\u015fma S\u00fcresi" } \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/nb.json b/homeassistant/components/uptimerobot/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/no.json b/homeassistant/components/uptimerobot/translations/no.json index df7a7f8045a8a4f7f7eb1f8eb42bcc86e3143abb..e3cbe428b645ddf6c2c29a34d9762b3a2791a03d 100644 --- a/homeassistant/components/uptimerobot/translations/no.json +++ b/homeassistant/components/uptimerobot/translations/no.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Kontoen er allerede konfigurert", "reauth_failed_existing": "Kunne ikke oppdatere konfigurasjonsoppf\u00f8ringen. Fjern integrasjonen og sett den opp igjen.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "unknown": "Uventet feil" }, "error": { diff --git a/homeassistant/components/uptimerobot/translations/zh-Hans.json b/homeassistant/components/uptimerobot/translations/zh-Hans.json index d680c09e967d3470c50bb41744d289a66af95843..f67afb7a2cb5c5981dc5dea8601e4563a5d7d4f7 100644 --- a/homeassistant/components/uptimerobot/translations/zh-Hans.json +++ b/homeassistant/components/uptimerobot/translations/zh-Hans.json @@ -17,7 +17,8 @@ "data": { "api_key": "API \u5bc6\u94a5" }, - "description": "\u60a8\u9700\u8981\u4ece Uptime Robot \u4e2d\u63d0\u4f9b\u4e00\u4e2a\"\u53ea\u8bfb API \u5bc6\u94a5\"" + "description": "\u60a8\u9700\u8981\u4ece Uptime Robot \u4e2d\u63d0\u4f9b\u4e00\u4e2a\"\u53ea\u8bfb API \u5bc6\u94a5\"", + "title": "\u91cd\u65b0\u8ba4\u8bc1\u96c6\u6210" }, "user": { "data": { diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 55ffe111a730b62ad778974767712bd1f0043c31..7c0355fa24cb3be01971d70ce5694ecfa9e200df 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -315,7 +315,7 @@ class USBDiscovery: async def websocket_usb_scan( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], ) -> None: """Scan for new usb devices.""" usb_discovery: USBDiscovery = hass.data[DOMAIN] diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index 9f1b30181862d18d05104e23a2ad01ad96970837..5e14b795dae20c5203a0e45cff155e6c5d9d2e9c 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -16,7 +16,7 @@ from . import ValloxDataUpdateCoordinator, ValloxEntity from .const import DOMAIN -class ValloxBinarySensor(ValloxEntity, BinarySensorEntity): +class ValloxBinarySensorEntity(ValloxEntity, BinarySensorEntity): """Representation of a Vallox binary sensor.""" entity_description: ValloxBinarySensorEntityDescription @@ -56,7 +56,7 @@ class ValloxBinarySensorEntityDescription( """Describes Vallox binary sensor entity.""" -SENSORS: tuple[ValloxBinarySensorEntityDescription, ...] = ( +BINARY_SENSOR_ENTITIES: tuple[ValloxBinarySensorEntityDescription, ...] = ( ValloxBinarySensorEntityDescription( key="post_heater", name="Post heater", @@ -77,7 +77,7 @@ async def async_setup_entry( async_add_entities( [ - ValloxBinarySensor(data["name"], data["coordinator"], description) - for description in SENSORS + ValloxBinarySensorEntity(data["name"], data["coordinator"], description) + for description in BINARY_SENSOR_ENTITIES ] ) diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index be496bbf8996e3696e70384fa6abcb26c96bb4c8..be713e34e25148266c452fd091b2d2827d848dd6 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -70,7 +70,7 @@ async def async_setup_entry( client = data["client"] client.set_settable_address(METRIC_KEY_MODE, int) - device = ValloxFan( + device = ValloxFanEntity( data["name"], client, data["coordinator"], @@ -79,7 +79,7 @@ async def async_setup_entry( async_add_entities([device]) -class ValloxFan(ValloxEntity, FanEntity): +class ValloxFanEntity(ValloxEntity, FanEntity): """Representation of the fan.""" _attr_supported_features = FanEntityFeature.PRESET_MODE diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 2e00452fdf284184554d683a7be704648671323b..c349107a3f381f84bbf6772e079d6e8cd2679114 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + REVOLUTIONS_PER_MINUTE, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -156,7 +157,7 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( metric_key="A_CYC_EXTR_FAN_SPEED", icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="RPM", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, entity_type=ValloxFanSpeedSensor, entity_registry_enabled_default=False, ), @@ -166,7 +167,7 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( metric_key="A_CYC_SUPP_FAN_SPEED", icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="RPM", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, entity_type=ValloxFanSpeedSensor, entity_registry_enabled_default=False, ), diff --git a/homeassistant/components/vallox/translations/nb.json b/homeassistant/components/vallox/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/vallox/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index d4229066eff26bf88b23600f3746756785a288d2..118d04d3c1ba9ed6190e02b14710615695564a9f 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -8,7 +8,7 @@ import vasttrafik import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_DELAY, CONF_NAME +from homeassistant.const import CONF_DELAY, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -22,7 +22,6 @@ ATTR_ACCESSIBILITY = "accessibility" ATTR_DIRECTION = "direction" ATTR_LINE = "line" ATTR_TRACK = "track" -ATTRIBUTION = "Data provided by Västtrafik" CONF_DEPARTURES = "departures" CONF_FROM = "from" @@ -83,6 +82,8 @@ def setup_platform( class VasttrafikDepartureSensor(SensorEntity): """Implementation of a Vasttrafik Departure Sensor.""" + _attr_attribution = "Data provided by Västtrafik" + def __init__(self, planner, name, departure, heading, lines, delay): """Initialize the sensor.""" self._planner = planner @@ -158,7 +159,6 @@ class VasttrafikDepartureSensor(SensorEntity): params = { ATTR_ACCESSIBILITY: departure.get("accessibility"), - ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_DIRECTION: departure.get("direction"), ATTR_LINE: departure.get("sname"), ATTR_TRACK: departure.get("track"), diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index eeeee2f97167995a0f53363609518e1f42b99f28..2ac751e283d03e238771d92717b1521de339a921 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -1,9 +1,11 @@ """Support for Velbus devices.""" from __future__ import annotations +from contextlib import suppress import logging +import os +import shutil -from velbusaio.channels import Channel as VelbusChannel from velbusaio.controller import Velbus import voluptuous as vol @@ -13,12 +15,13 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.storage import STORAGE_DIR from .const import ( CONF_INTERFACE, CONF_MEMO_TEXT, DOMAIN, + SERVICE_CLEAR_CACHE, SERVICE_SCAN, SERVICE_SET_MEMO_TEXT, SERVICE_SYNC, @@ -66,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: controller = Velbus( entry.data[CONF_PORT], - cache_dir=hass.config.path(".storage/velbuscache/"), + cache_dir=hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}"), ) hass.data[DOMAIN][entry.entry_id] = {} hass.data[DOMAIN][entry.entry_id]["cntrl"] = controller @@ -132,11 +135,47 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), ) + async def clear_cache(call: ServiceCall) -> None: + """Handle a clear cache service call.""" + # clear the cache + with suppress(FileNotFoundError): + if call.data[CONF_ADDRESS]: + await hass.async_add_executor_job( + os.unlink, + hass.config.path( + STORAGE_DIR, + f"velbuscache-{call.data[CONF_INTERFACE]}/{call.data[CONF_ADDRESS]}.p", + ), + ) + else: + await hass.async_add_executor_job( + shutil.rmtree, + hass.config.path( + STORAGE_DIR, f"velbuscache-{call.data[CONF_INTERFACE]}/" + ), + ) + # call a scan to repopulate + await scan(call) + + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_CACHE, + clear_cache, + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), + ) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Remove the velbus connection.""" + """Unload (close) the velbus connection.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) await hass.data[DOMAIN][entry.entry_id]["cntrl"].stop() hass.data[DOMAIN].pop(entry.entry_id) @@ -145,33 +184,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.services.async_remove(DOMAIN, SERVICE_SCAN) hass.services.async_remove(DOMAIN, SERVICE_SYNC) hass.services.async_remove(DOMAIN, SERVICE_SET_MEMO_TEXT) + hass.services.async_remove(DOMAIN, SERVICE_CLEAR_CACHE) return unload_ok -class VelbusEntity(Entity): - """Representation of a Velbus entity.""" - - _attr_should_poll: bool = False - - def __init__(self, channel: VelbusChannel) -> None: - """Initialize a Velbus entity.""" - self._channel = channel - self._attr_name = channel.get_name() - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, str(channel.get_module_address())), - }, - manufacturer="Velleman", - model=channel.get_module_type_name(), - name=channel.get_full_name(), - sw_version=channel.get_module_sw_version(), - ) - serial = channel.get_module_serial() or str(channel.get_module_address()) - self._attr_unique_id = f"{serial}-{channel.get_channel_number()}" - - async def async_added_to_hass(self) -> None: - """Add listener for state changes.""" - self._channel.on_status_update(self._on_update) - - async def _on_update(self) -> None: - self.async_write_ha_state() +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove the velbus entry, so we also have to cleanup the cache dir.""" + await hass.async_add_executor_job( + shutil.rmtree, + hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}"), + ) diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py index 8c67520dd9adfdb25c309ca2818313217cb7f985..ef0cef938b18f736c0dc63cd3f3a2f0aa4b3eac4 100644 --- a/homeassistant/components/velbus/binary_sensor.py +++ b/homeassistant/components/velbus/binary_sensor.py @@ -6,8 +6,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN +from .entity import VelbusEntity async def async_setup_entry( diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py index 189cfb495e46b34413f0ae6dc2a41a39a9c43184..5f76f7bba9800f76c2da5c86be33f28cd7cbd456 100644 --- a/homeassistant/components/velbus/button.py +++ b/homeassistant/components/velbus/button.py @@ -12,8 +12,8 @@ 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 +from .entity import VelbusEntity async def async_setup_entry( diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index a6549f0262c7f14c705a79d25a33b6f34cfd432b..76eb3e30fa0db500338c874059502e0913517d8f 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -15,8 +15,8 @@ from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN, PRESET_MODES +from .entity import VelbusEntity async def async_setup_entry( diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index 7c41274f11c13578a2ab7fe02ccb7ab1928e2559..a3949646598d3b719b4736e649397c105933947b 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -16,6 +16,7 @@ CONF_MEMO_TEXT: Final = "memo_text" SERVICE_SCAN: Final = "scan" SERVICE_SYNC: Final = "sync_clock" SERVICE_SET_MEMO_TEXT: Final = "set_memo_text" +SERVICE_CLEAR_CACHE: Final = "clear_cache" PRESET_MODES: Final = { PRESET_ECO: "safe", diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index 6bd1629d3a323a3dbe950886bf1ee03f361114f8..009c4fadfb9c328af351d26e898e4700c35c704a 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -14,8 +14,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN +from .entity import VelbusEntity async def async_setup_entry( @@ -38,7 +38,7 @@ class VelbusCover(VelbusEntity, CoverEntity): _channel: VelbusBlind def __init__(self, channel: VelbusBlind) -> None: - """Initialize the dimmer.""" + """Initialize the cover.""" super().__init__(channel) if self._channel.support_position(): self._attr_supported_features = ( @@ -59,6 +59,16 @@ class VelbusCover(VelbusEntity, CoverEntity): """Return if the cover is closed.""" return self._channel.is_closed() + @property + def is_opening(self) -> bool: + """Return if the cover is opening.""" + return self._channel.is_opening() + + @property + def is_closing(self) -> bool: + """Return if the cover is closing.""" + return self._channel.is_closing() + @property def current_cover_position(self) -> int | None: """Return current position of cover. @@ -66,7 +76,10 @@ class VelbusCover(VelbusEntity, CoverEntity): None is unknown, 0 is closed, 100 is fully open Velbus: 100 = closed, 0 = open """ - return 100 - self._channel.get_position() + pos = self._channel.get_position() + if pos is not None: + return 100 - pos + return None async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py new file mode 100644 index 0000000000000000000000000000000000000000..13ecb7febab5faf78727b7c43b6a7ca5c2cc23dc --- /dev/null +++ b/homeassistant/components/velbus/entity.py @@ -0,0 +1,37 @@ +"""Support for Velbus devices.""" +from __future__ import annotations + +from velbusaio.channels import Channel as VelbusChannel + +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +class VelbusEntity(Entity): + """Representation of a Velbus entity.""" + + _attr_should_poll: bool = False + + def __init__(self, channel: VelbusChannel) -> None: + """Initialize a Velbus entity.""" + self._channel = channel + self._attr_name = channel.get_name() + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, str(channel.get_module_address())), + }, + manufacturer="Velleman", + model=channel.get_module_type_name(), + name=channel.get_full_name(), + sw_version=channel.get_module_sw_version(), + ) + serial = channel.get_module_serial() or str(channel.get_module_address()) + self._attr_unique_id = f"{serial}-{channel.get_channel_number()}" + + async def async_added_to_hass(self) -> None: + """Add listener for state changes.""" + self._channel.on_status_update(self._on_update) + + async def _on_update(self) -> None: + self.async_write_ha_state() diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index f562e250892b4c5176a4ec4420165975516720cc..b5b106b6f9fd599a42fe6577eabe97f1e21ddb92 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -24,8 +24,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN +from .entity import VelbusEntity async def async_setup_entry( diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 1a5d78d24d68010a27aad473745d82088eaeed90..86e67ca7767fa1b16e17f45300f6cb4de5666748 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -2,10 +2,11 @@ "domain": "velbus", "name": "Velbus", "documentation": "https://www.home-assistant.io/integrations/velbus", - "requirements": ["velbus-aio==2022.10.2"], + "requirements": ["velbus-aio==2022.10.4"], "config_flow": true, "codeowners": ["@Cereal2nd", "@brefra"], "dependencies": ["usb"], + "integration_type": "hub", "iot_class": "local_push", "usb": [ { diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index a0bd9b6c1732a4ae254f38c2cf273f247917b5b4..0805ae2699ac1769234143db3e8d7ac1cddcb747 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -12,8 +12,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN +from .entity import VelbusEntity async def async_setup_entry( diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml index 6dbf5e8cb4723dbcfffa4cccecad60bdc5ec5288..32cda00f7083016b32567f769385652c9b7117a5 100644 --- a/homeassistant/components/velbus/services.yaml +++ b/homeassistant/components/velbus/services.yaml @@ -24,6 +24,29 @@ scan: selector: text: +clear_cache: + name: Clear cache + description: Clears the velbuscache and then starts a new scan + fields: + interface: + name: Interface + description: The velbus interface to send the command to, this will be the same value as used during configuration + required: true + example: "192.168.1.5:27015" + default: "" + selector: + text: + address: + name: Address + description: > + The module address in decimal format, if this is provided we only clear this module, if nothing is provided we clear the whole cache directory (all modules) + The decimal addresses are displayed in front of the modules listed at the integration page. + required: false + selector: + number: + min: 1 + max: 254 + set_memo_text: name: Set memo text description: > diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py index c3c4c8a586360a0d558567031af177c351fe82a9..6de8373d3fc6a8c2edb87d91c2a1bbe5c23220ca 100644 --- a/homeassistant/components/velbus/switch.py +++ b/homeassistant/components/velbus/switch.py @@ -8,8 +8,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VelbusEntity from .const import DOMAIN +from .entity import VelbusEntity async def async_setup_entry( diff --git a/homeassistant/components/venstar/translations/nb.json b/homeassistant/components/venstar/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/venstar/translations/nb.json +++ b/homeassistant/components/venstar/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 820b8a20f145469b7ba5908404b5c41954c172d1..cc4f90bb92bc71ef170313171747ab8464acc88e 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -11,5 +11,6 @@ } ], "iot_class": "cloud_polling", + "integration_type": "hub", "loggers": ["verisure"] } diff --git a/homeassistant/components/verisure/translations/bg.json b/homeassistant/components/verisure/translations/bg.json index f5447e1d8650773fa3056d27234c2ad585265728..927c79f2674dc12147c80110d269657d4b89e19d 100644 --- a/homeassistant/components/verisure/translations/bg.json +++ b/homeassistant/components/verisure/translations/bg.json @@ -13,10 +13,20 @@ "code": "\u041a\u043e\u0434 \u0437\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430" } }, + "reauth_confirm": { + "data": { + "email": "\u0418\u043c\u0435\u0439\u043b" + } + }, "reauth_mfa": { "data": { "code": "\u041a\u043e\u0434 \u0437\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430" } + }, + "user": { + "data": { + "email": "\u0418\u043c\u0435\u0439\u043b" + } } } } diff --git a/homeassistant/components/verisure/translations/nb.json b/homeassistant/components/verisure/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/verisure/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/verisure/translations/no.json b/homeassistant/components/verisure/translations/no.json index 195f8aadd3d0625cee675ef5d00647ae34cf7fce..4c29acd5a32efb4682e66b9b3faf8b15fd60340f 100644 --- a/homeassistant/components/verisure/translations/no.json +++ b/homeassistant/components/verisure/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/vesync/translations/bg.json b/homeassistant/components/vesync/translations/bg.json index c435a669d5a45dc8f88aaa628965ccc4a306b34c..56cdd7e1d91f7c54e126268ef75b85861e2cbd50 100644 --- a/homeassistant/components/vesync/translations/bg.json +++ b/homeassistant/components/vesync/translations/bg.json @@ -7,7 +7,7 @@ "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "E-mail \u0430\u0434\u0440\u0435\u0441" + "username": "\u0418\u043c\u0435\u0439\u043b" }, "title": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430" } diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index acb6ffa647e516191aefc9236abf42013b662b30..bdc8909da0f2c028547ebba6c4bacb97b0b7cc2c 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -11,7 +11,7 @@ import async_timeout import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, TIME_MINUTES +from homeassistant.const import TIME_MINUTES from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -20,8 +20,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Powered by ViaggiaTreno Data" - VIAGGIATRENO_ENDPOINT = ( "http://www.viaggiatreno.it/infomobilita/" "resteasy/viaggiatreno/andamentoTreno/" @@ -96,6 +94,8 @@ async def async_http_request(hass, uri): class ViaggiaTrenoSensor(SensorEntity): """Implementation of a ViaggiaTreno sensor.""" + _attr_attribution = "Powered by ViaggiaTreno Data" + def __init__(self, train_id, station_id, name): """Initialize the sensor.""" self._state = None @@ -132,7 +132,6 @@ class ViaggiaTrenoSensor(SensorEntity): @property def extra_state_attributes(self): """Return extra attributes.""" - self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION return self._attributes @staticmethod diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index e1deef0df00dc44f206e0a2b228ef90dfdd0d12b..86f2f3931387ef708cfa4241fe6bf405d62ea2ca 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -86,6 +86,38 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="primary_circuit_supply_temperature", + name="Primary Circuit Supply Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getSupplyTemperaturePrimaryCircuit(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ViCareSensorEntityDescription( + key="primary_circuit_return_temperature", + name="Primary Circuit Return Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getReturnTemperaturePrimaryCircuit(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ViCareSensorEntityDescription( + key="secondary_circuit_supply_temperature", + name="Secondary Circuit Supply Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getSupplyTemperatureSecondaryCircuit(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ViCareSensorEntityDescription( + key="secondary_circuit_return_temperature", + name="Secondary Circuit Return Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getReturnTemperatureSecondaryCircuit(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), ViCareSensorEntityDescription( key="hotwater_out_temperature", name="Hot Water Out Temperature", @@ -94,6 +126,22 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="hotwater_max_temperature", + name="Hot Water Max Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getDomesticHotWaterMaxTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ViCareSensorEntityDescription( + key="hotwater_min_temperature", + name="Hot Water Min Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getDomesticHotWaterMinTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_today", name="Hot water gas consumption today", diff --git a/homeassistant/components/vicare/translations/bg.json b/homeassistant/components/vicare/translations/bg.json index 242339c3815433dfa4c7cd64d51c99de7cb68ef2..e6c4900778838a22ec78d4c5f3a22313697fed3b 100644 --- a/homeassistant/components/vicare/translations/bg.json +++ b/homeassistant/components/vicare/translations/bg.json @@ -13,7 +13,7 @@ "data": { "client_id": "API \u043a\u043b\u044e\u0447", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "Email" + "username": "\u0418\u043c\u0435\u0439\u043b" } } } diff --git a/homeassistant/components/vicare/translations/nb.json b/homeassistant/components/vicare/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..11a4fc139b877d5513a8e5dcf82651c8abc328de --- /dev/null +++ b/homeassistant/components/vicare/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vilfo/translations/nb.json b/homeassistant/components/vilfo/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/vilfo/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 5b534f861cc37f7246f32e46a17a2479428a5527..3fe0ac45885034ce4eb1bc507c8a6dd7124f7694 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -8,5 +8,6 @@ "zeroconf": ["_viziocast._tcp.local."], "quality_scale": "platinum", "iot_class": "local_polling", - "loggers": ["pyvizio"] + "loggers": ["pyvizio"], + "integration_type": "hub" } diff --git a/homeassistant/components/vlc_telnet/translations/nb.json b/homeassistant/components/vlc_telnet/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..d00b0b5126750f84db30ce0a88279b78818adabe --- /dev/null +++ b/homeassistant/components/vlc_telnet/translations/nb.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "unknown": "Uventet feil" + }, + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vlc_telnet/translations/no.json b/homeassistant/components/vlc_telnet/translations/no.json index 9becf574700c819b466018aee49a1e84077f62b7..d3e8a3005d70a80957649d03b1db20e2f8d30455 100644 --- a/homeassistant/components/vlc_telnet/translations/no.json +++ b/homeassistant/components/vlc_telnet/translations/no.json @@ -4,7 +4,7 @@ "already_configured": "Tjenesten er allerede konfigurert", "cannot_connect": "Tilkobling mislyktes", "invalid_auth": "Ugyldig godkjenning", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket", + "reauth_successful": "Re-autentisering var vellykket", "unknown": "Uventet feil" }, "error": { diff --git a/homeassistant/components/volumio/translations/nb.json b/homeassistant/components/volumio/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/volumio/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 5a24712b7a364a4fc09bf9b47c8487279b92f467..65bc6c1cfbe3985f6c9aa0f78179cfaede020667 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -23,6 +23,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed 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 DeviceInfo from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( @@ -314,6 +315,16 @@ class VolvoEntity(CoordinatorEntity): """Return true if unable to access real state of entity.""" return True + @property + def device_info(self) -> DeviceInfo: + """Return a inique set of attributes for each vehicle.""" + return DeviceInfo( + identifiers={(DOMAIN, self.vehicle.vin)}, + name=self._vehicle_name, + model=self.vehicle.vehicle_type, + manufacturer="Volvo", + ) + @property def extra_state_attributes(self): """Return device specific state attributes.""" diff --git a/homeassistant/components/volvooncall/translations/bg.json b/homeassistant/components/volvooncall/translations/bg.json index 598cf3c837ff76388c3903bda504ed2ff385cd0a..62a0a14568db1f3ef2ef96cdc631998ff7b9bd4b 100644 --- a/homeassistant/components/volvooncall/translations/bg.json +++ b/homeassistant/components/volvooncall/translations/bg.json @@ -15,6 +15,7 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "region": "\u0420\u0435\u0433\u0438\u043e\u043d", "scandinavian_miles": "\u0418\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u0441\u043a\u0430\u043d\u0434\u0438\u043d\u0430\u0432\u0441\u043a\u0438 \u043c\u0438\u043b\u0438", + "unit_system": "\u0421\u0438\u0441\u0442\u0435\u043c\u0430 \u0435\u0434\u0438\u043d\u0438\u0446\u0438", "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" } } diff --git a/homeassistant/components/volvooncall/translations/et.json b/homeassistant/components/volvooncall/translations/et.json index 740e0bbc68cfc54950e61c1a62d692c5db57c239..9f2912b5d5345eaf7fd9c22ea107b3eb01405bc7 100644 --- a/homeassistant/components/volvooncall/translations/et.json +++ b/homeassistant/components/volvooncall/translations/et.json @@ -15,6 +15,7 @@ "password": "Salas\u00f5na", "region": "Piirkond", "scandinavian_miles": "Kasuta Scandinavian Miles", + "unit_system": "\u00dchikute s\u00fcsteem", "username": "Kasutajanimi" } } diff --git a/homeassistant/components/volvooncall/translations/he.json b/homeassistant/components/volvooncall/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..6f2cdbf82e128df4833df7c7dd5760bb1c9a1216 --- /dev/null +++ b/homeassistant/components/volvooncall/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volvooncall/translations/ja.json b/homeassistant/components/volvooncall/translations/ja.json index 0f56a700da00c7b577d72f40e5ceb02f5d56a1d0..4127b96671026a71b0090c28ce1f16faff79a79b 100644 --- a/homeassistant/components/volvooncall/translations/ja.json +++ b/homeassistant/components/volvooncall/translations/ja.json @@ -15,6 +15,7 @@ "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", "region": "\u30ea\u30fc\u30b8\u30e7\u30f3", "scandinavian_miles": "\u30b9\u30ab\u30f3\u30b8\u30ca\u30d3\u30a2\u30de\u30a4\u30eb(Scandinavian Miles)\u3092\u4f7f\u7528\u3059\u308b", + "unit_system": "\u5358\u4f4d\u30b7\u30b9\u30c6\u30e0", "username": "\u30e6\u30fc\u30b6\u30fc\u540d" } } diff --git a/homeassistant/components/volvooncall/translations/nb.json b/homeassistant/components/volvooncall/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/volvooncall/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volvooncall/translations/no.json b/homeassistant/components/volvooncall/translations/no.json index 2d60c5983fc03f0d28f0c43b26e597930025a5c6..48639f07b67fd9949f2ab311e380307abdc486b1 100644 --- a/homeassistant/components/volvooncall/translations/no.json +++ b/homeassistant/components/volvooncall/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/volvooncall/translations/sv.json b/homeassistant/components/volvooncall/translations/sv.json index 03658c137df3b054e94f07622e73920ae79776c3..48d56656c6ad6d8520966c5e1bd3a7c010157dae 100644 --- a/homeassistant/components/volvooncall/translations/sv.json +++ b/homeassistant/components/volvooncall/translations/sv.json @@ -15,6 +15,7 @@ "password": "L\u00f6senord", "region": "Region", "scandinavian_miles": "Anv\u00e4nd Skandinaviska mil", + "unit_system": "Enhetssystem", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/volvooncall/translations/tr.json b/homeassistant/components/volvooncall/translations/tr.json index 0b56c9b67b62a0dab428f80a15be578cf72d9b80..4db9497008692a53dc689742d71247c7d99bd5f8 100644 --- a/homeassistant/components/volvooncall/translations/tr.json +++ b/homeassistant/components/volvooncall/translations/tr.json @@ -15,6 +15,7 @@ "password": "Parola", "region": "B\u00f6lge", "scandinavian_miles": "\u0130skandinav Millerini Kullan\u0131n", + "unit_system": "Birim Sistemi", "username": "Kullan\u0131c\u0131 Ad\u0131" } } diff --git a/homeassistant/components/vulcan/translations/bg.json b/homeassistant/components/vulcan/translations/bg.json index f99cd3cca145e7722ac392fd14c96d3effaa4f44..db0b6604e3f880a80f685b6f9b166e27dbd8bff7 100644 --- a/homeassistant/components/vulcan/translations/bg.json +++ b/homeassistant/components/vulcan/translations/bg.json @@ -8,6 +8,11 @@ "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": { + "auth": { + "data": { + "region": "\u0421\u0438\u043c\u0432\u043e\u043b" + } + }, "select_saved_credentials": { "data": { "credentials": "\u0412\u0445\u043e\u0434" diff --git a/homeassistant/components/wallbox/translations/nb.json b/homeassistant/components/wallbox/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/wallbox/translations/nb.json +++ b/homeassistant/components/wallbox/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/wallbox/translations/no.json b/homeassistant/components/wallbox/translations/no.json index 498362fad1db735a0328153ef63fa8b9139195a2..c4cf220e5e558c4c166f74a21eeb5178a6eeee15 100644 --- a/homeassistant/components/wallbox/translations/no.json +++ b/homeassistant/components/wallbox/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/water_heater/translations/bg.json b/homeassistant/components/water_heater/translations/bg.json index b751234eaea9eade66b80fd6ec13cd5b487cba7c..c80c861f5ddf530b42aa584ed037b9965deed913 100644 --- a/homeassistant/components/water_heater/translations/bg.json +++ b/homeassistant/components/water_heater/translations/bg.json @@ -4,5 +4,10 @@ "turn_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 {entity_name}", "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 {entity_name}" } + }, + "state": { + "_": { + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d" + } } } \ No newline at end of file diff --git a/homeassistant/components/watttime/diagnostics.py b/homeassistant/components/watttime/diagnostics.py index 080c7c37b07bb1f658d34cf48881ab170b56a7fb..2808e8e3c3509454ef557be68f5035bc0d291e4a 100644 --- a/homeassistant/components/watttime/diagnostics.py +++ b/homeassistant/components/watttime/diagnostics.py @@ -9,17 +9,25 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_PASSWORD, + CONF_UNIQUE_ID, CONF_USERNAME, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, DOMAIN + +CONF_TITLE = "title" TO_REDACT = { + CONF_BALANCING_AUTHORITY, + CONF_BALANCING_AUTHORITY_ABBREV, CONF_LATITUDE, CONF_LONGITUDE, CONF_PASSWORD, + # Config entry title and unique ID may contain sensitive data: + CONF_TITLE, + CONF_UNIQUE_ID, CONF_USERNAME, } @@ -32,10 +40,7 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { - "entry": { - "data": dict(entry.data), - "options": dict(entry.options), - }, + "entry": entry.as_dict(), "data": coordinator.data, }, TO_REDACT, diff --git a/homeassistant/components/watttime/manifest.json b/homeassistant/components/watttime/manifest.json index 1f233b5e1056ec8b4efc770bca78f711aeb9f673..b661b968373e14c61800f3810840895cdeb82537 100644 --- a/homeassistant/components/watttime/manifest.json +++ b/homeassistant/components/watttime/manifest.json @@ -6,5 +6,6 @@ "requirements": ["aiowatttime==0.1.1"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", - "loggers": ["aiowatttime"] + "loggers": ["aiowatttime"], + "integration_type": "service" } diff --git a/homeassistant/components/watttime/translations/nb.json b/homeassistant/components/watttime/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/watttime/translations/nb.json +++ b/homeassistant/components/watttime/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/watttime/translations/no.json b/homeassistant/components/watttime/translations/no.json index 19ec82e863cdad79a5083e6874d225b78b436bd5..5b94a79bad22976015becdcb68ab26353986d06c 100644 --- a/homeassistant/components/watttime/translations/no.json +++ b/homeassistant/components/watttime/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 4e82af5119c9904a3476c660a3025578dbe9721d..806672b3608aa86ba9b5f2cc8b723898c298c274 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -2,24 +2,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, - async_get, -) PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Load the saved entities.""" - if entry.unique_id is not None: - hass.config_entries.async_update_entry(entry, unique_id=None) - - ent_reg = async_get(hass) - for entity in async_entries_for_config_entry(ent_reg, entry.entry_id): - ent_reg.async_update_entity(entity.entity_id, new_unique_id=entry.entry_id) - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index 45aeada2a7a5da6d37711fd309b1aa3e10cd2b0a..b26732e4cb1faf75d2627ffce4e398b9116c3d23 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -5,8 +5,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_NAME, CONF_REGION -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( CONF_AVOID_FERRIES, @@ -20,7 +22,9 @@ from .const import ( CONF_UNITS, CONF_VEHICLE_TYPE, DEFAULT_NAME, + DEFAULT_OPTIONS, DOMAIN, + IMPERIAL_UNITS, REGIONS, UNITS, VEHICLE_TYPES, @@ -28,6 +32,14 @@ from .const import ( from .helpers import is_valid_config_entry +def default_options(hass: HomeAssistant) -> dict[str, str | bool]: + """Get the default options.""" + defaults = DEFAULT_OPTIONS.copy() + if hass.config.units is US_CUSTOMARY_SYSTEM: + defaults[CONF_UNITS] = IMPERIAL_UNITS + return defaults + + class WazeOptionsFlow(config_entries.OptionsFlow): """Handle an options flow for Waze Travel Time.""" @@ -35,12 +47,12 @@ class WazeOptionsFlow(config_entries.OptionsFlow): """Initialize waze options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input=None) -> FlowResult: """Handle the initial step.""" if user_input is not None: return self.async_create_entry( title="", - data={k: v for k, v in user_input.items() if v not in (None, "")}, + data=user_input, ) return self.async_show_form( @@ -99,7 +111,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return WazeOptionsFlow(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """Handle the initial step.""" errors = {} user_input = user_input or {} @@ -115,6 +127,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=user_input.get(CONF_NAME, DEFAULT_NAME), data=user_input, + options=default_options(self.hass), ) # If we get here, it's because we couldn't connect @@ -134,5 +147,3 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) - - async_step_import = async_step_user diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index 37278543dfb6b1e3188873e362f0d909390ca488..1121519f8cd91a100074b61296ec957ab5981a78 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -1,5 +1,5 @@ """Constants for waze_travel_time.""" -from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC +from __future__ import annotations DOMAIN = "waze_travel_time" @@ -21,7 +21,18 @@ DEFAULT_AVOID_TOLL_ROADS = False DEFAULT_AVOID_SUBSCRIPTION_ROADS = False DEFAULT_AVOID_FERRIES = False -UNITS = [CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL] +IMPERIAL_UNITS = "imperial" +METRIC_UNITS = "metric" +UNITS = [METRIC_UNITS, IMPERIAL_UNITS] REGIONS = ["US", "NA", "EU", "IL", "AU"] VEHICLE_TYPES = ["car", "taxi", "motorcycle"] + +DEFAULT_OPTIONS: dict[str, str | bool] = { + CONF_REALTIME: DEFAULT_REALTIME, + CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, + CONF_UNITS: METRIC_UNITS, + CONF_AVOID_FERRIES: DEFAULT_AVOID_FERRIES, + CONF_AVOID_SUBSCRIPTION_ROADS: DEFAULT_AVOID_SUBSCRIPTION_ROADS, + CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, +} diff --git a/homeassistant/components/waze_travel_time/helpers.py b/homeassistant/components/waze_travel_time/helpers.py index 67d8b5674b282f2b43902f9e0dc98b90c417c303..8468bb8ea9a6a0070a7f41eafcddfdbc8ffe7a8c 100644 --- a/homeassistant/components/waze_travel_time/helpers.py +++ b/homeassistant/components/waze_travel_time/helpers.py @@ -1,8 +1,12 @@ """Helpers for Waze Travel Time integration.""" +import logging + from WazeRouteCalculator import WazeRouteCalculator, WRCError from homeassistant.helpers.location import find_coordinates +_LOGGER = logging.getLogger(__name__) + def is_valid_config_entry(hass, origin, destination, region): """Return whether the config entry data is valid.""" @@ -10,6 +14,7 @@ def is_valid_config_entry(hass, origin, destination, region): destination = find_coordinates(hass, destination) try: WazeRouteCalculator(origin, destination, region).calc_all_routes_info() - except WRCError: + except WRCError as error: + _LOGGER.error("Error trying to validate entry: %s", error) return False return True diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 153ada113498b9a4c92359292e4f79b5bd68a1e0..c8d3e308435aa9d0238057e9e2fab9cd4e0d0352 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -13,12 +13,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_NAME, CONF_REGION, - CONF_UNIT_SYSTEM_IMPERIAL, EVENT_HOMEASSISTANT_STARTED, LENGTH_KILOMETERS, + LENGTH_MILES, TIME_MINUTES, ) from homeassistant.core import CoreState, HomeAssistant @@ -26,7 +25,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.location import find_coordinates -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_conversion import DistanceConverter from .const import ( CONF_AVOID_FERRIES, @@ -39,13 +38,9 @@ from .const import ( CONF_REALTIME, CONF_UNITS, CONF_VEHICLE_TYPE, - DEFAULT_AVOID_FERRIES, - DEFAULT_AVOID_SUBSCRIPTION_ROADS, - DEFAULT_AVOID_TOLL_ROADS, DEFAULT_NAME, - DEFAULT_REALTIME, - DEFAULT_VEHICLE_TYPE, DOMAIN, + IMPERIAL_UNITS, ) _LOGGER = logging.getLogger(__name__) @@ -59,37 +54,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Waze travel time sensor entry.""" - defaults = { - CONF_REALTIME: DEFAULT_REALTIME, - CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, - CONF_UNITS: hass.config.units.name, - CONF_AVOID_FERRIES: DEFAULT_AVOID_FERRIES, - CONF_AVOID_SUBSCRIPTION_ROADS: DEFAULT_AVOID_SUBSCRIPTION_ROADS, - CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, - } - - if not config_entry.options: - new_data = config_entry.data.copy() - options = {} - for key in ( - CONF_INCL_FILTER, - CONF_EXCL_FILTER, - CONF_REALTIME, - CONF_VEHICLE_TYPE, - CONF_AVOID_TOLL_ROADS, - CONF_AVOID_SUBSCRIPTION_ROADS, - CONF_AVOID_FERRIES, - CONF_UNITS, - ): - if key in new_data: - options[key] = new_data.pop(key) - elif key in defaults: - options[key] = defaults[key] - - hass.config_entries.async_update_entry( - config_entry, data=new_data, options=options - ) - destination = config_entry.data[CONF_DESTINATION] origin = config_entry.data[CONF_ORIGIN] region = config_entry.data[CONF_REGION] @@ -110,6 +74,7 @@ async def async_setup_entry( class WazeTravelTime(SensorEntity): """Representation of a Waze travel time sensor.""" + _attr_attribution = "Powered by Waze" _attr_native_unit_of_measurement = TIME_MINUTES _attr_device_class = SensorDeviceClass.DURATION _attr_state_class = SensorStateClass.MEASUREMENT @@ -154,7 +119,6 @@ class WazeTravelTime(SensorEntity): return None return { - ATTR_ATTRIBUTION: "Powered by Waze", "duration": self._waze_data.duration, "distance": self._waze_data.distance, "route": self._waze_data.route, @@ -221,14 +185,14 @@ class WazeTravelTimeData: ) routes = params.calc_all_routes_info(real_time=realtime) - if incl_filter is not None: + if incl_filter not in {None, ""}: routes = { k: v for k, v in routes.items() if incl_filter.lower() in k.lower() } - if excl_filter is not None: + if excl_filter not in {None, ""}: routes = { k: v for k, v in routes.items() @@ -243,9 +207,11 @@ class WazeTravelTimeData: self.duration, distance = routes[route] - if units == CONF_UNIT_SYSTEM_IMPERIAL: + if units == IMPERIAL_UNITS: # Convert to miles. - self.distance = IMPERIAL_SYSTEM.length(distance, LENGTH_KILOMETERS) + self.distance = DistanceConverter.convert( + distance, LENGTH_KILOMETERS, LENGTH_MILES + ) else: self.distance = distance diff --git a/homeassistant/components/waze_travel_time/translations/bg.json b/homeassistant/components/waze_travel_time/translations/bg.json index fb5df0326711c51484032010a289bc90a5fdbbcf..5b18b5ba0219c5ff6536bd46eb36fdcbfbc07057 100644 --- a/homeassistant/components/waze_travel_time/translations/bg.json +++ b/homeassistant/components/waze_travel_time/translations/bg.json @@ -11,5 +11,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "units": "\u0415\u0434\u0438\u043d\u0438\u0446\u0438" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 2014c5b4eedbda1aa37369fd91204a6f2d4fc9bc..8ffced6d5d22a2356660d93f06761b00db29ddd3 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -44,6 +44,7 @@ from homeassistant.util.unit_conversion import ( SpeedConverter, TemperatureConverter, ) +from homeassistant.util.unit_system import METRIC_SYSTEM _LOGGER = logging.getLogger(__name__) @@ -419,7 +420,9 @@ class WeatherEntity(Entity): Should not be set by integrations. """ - return PRESSURE_HPA if self.hass.config.units.is_metric else PRESSURE_INHG + return ( + PRESSURE_HPA if self.hass.config.units is METRIC_SYSTEM else PRESSURE_INHG + ) @final @property @@ -483,7 +486,7 @@ class WeatherEntity(Entity): """ return ( SPEED_KILOMETERS_PER_HOUR - if self.hass.config.units.is_metric + if self.hass.config.units is METRIC_SYSTEM else SPEED_MILES_PER_HOUR ) diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 449de006bf934c20bc546ab9a86b667166e92ee4..bd80f38b832446a8651b054c2a7b0d4a4286513f 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -168,7 +168,11 @@ class WebhookView(HomeAssistantView): } ) @callback -def websocket_list(hass, connection, msg): +def websocket_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Return a list of webhooks.""" handlers = hass.data.setdefault(DOMAIN, {}) result = [ @@ -195,7 +199,11 @@ def websocket_list(hass, connection, msg): } ) @websocket_api.async_response -async def websocket_handle(hass, connection, msg): +async def websocket_handle( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: """Handle an incoming webhook via the WS API.""" request = MockRequest( content=msg["body"].encode("utf-8"), diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..ce62f51b540e7a8fe13e2a44a0e077f78dc24257 --- /dev/null +++ b/homeassistant/components/webostv/diagnostics.py @@ -0,0 +1,52 @@ +"""Diagnostics support for LG webOS Smart TV.""" +from __future__ import annotations + +from typing import Any + +from aiowebostv import WebOsClient + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant + +from .const import DATA_CONFIG_ENTRY, DOMAIN + +TO_REDACT = { + CONF_CLIENT_SECRET, + CONF_UNIQUE_ID, + CONF_HOST, + "device_id", + "deviceUUID", + "icon", + "largeIcon", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + client: WebOsClient = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].client + + client_data = { + "is_registered": client.is_registered(), + "is_connected": client.is_connected(), + "current_app_id": client.current_app_id, + "current_channel": client.current_channel, + "apps": client.apps, + "inputs": client.inputs, + "system_info": client.system_info, + "software_info": client.software_info, + "hello_info": client.hello_info, + "sound_output": client.sound_output, + "is_on": client.is_on, + } + + return async_redact_data( + { + "entry": entry.as_dict(), + "client": client_data, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a78099e60655ecbe246c9efa0eff45e69506b91f..19ec505e4491256048e5c39019762774e07c22a4 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -12,7 +12,7 @@ from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_READ from homeassistant.const import ( EVENT_STATE_CHANGED, MATCH_ALL, - SIGNAL_BOOTSTRAP_INTEGRATONS, + SIGNAL_BOOTSTRAP_INTEGRATIONS, ) from homeassistant.core import Context, Event, HomeAssistant, State, callback from homeassistant.exceptions import ( @@ -21,7 +21,6 @@ from homeassistant.exceptions import ( TemplateError, Unauthorized, ) -from homeassistant.generated import supported_brands from homeassistant.helpers import config_validation as cv, entity, template from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import ( @@ -74,7 +73,6 @@ def async_register_commands( async_reg(hass, handle_unsubscribe_events) async_reg(hass, handle_validate_config) async_reg(hass, handle_subscribe_entities) - async_reg(hass, handle_supported_brands) async_reg(hass, handle_supported_features) async_reg(hass, handle_integration_descriptions) @@ -151,7 +149,7 @@ def handle_subscribe_bootstrap_integrations( connection.send_message(messages.event_message(msg["id"], message)) connection.subscriptions[msg["id"]] = async_dispatcher_connect( - hass, SIGNAL_BOOTSTRAP_INTEGRATONS, forward_bootstrap_integrations + hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, forward_bootstrap_integrations ) connection.send_result(msg["id"]) @@ -705,31 +703,6 @@ async def handle_validate_config( connection.send_result(msg["id"], result) -@decorators.websocket_command( - { - vol.Required("type"): "supported_brands", - } -) -@decorators.async_response -async def handle_supported_brands( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Handle supported brands command.""" - data = {} - - ints_or_excs = await async_get_integrations( - hass, supported_brands.HAS_SUPPORTED_BRANDS - ) - for int_or_exc in ints_or_excs.values(): - if isinstance(int_or_exc, Exception): - raise int_or_exc - # Happens if a custom component without supported brands overrides a built-in one with supported brands - if "supported_brands" not in int_or_exc.manifest: - continue - data[int_or_exc.domain] = int_or_exc.manifest["supported_brands"] - connection.send_result(msg["id"], data) - - @callback @decorators.websocket_command( { diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index c344e1c6a9fd4ec7fb8efacfd0fa36bcde7dee3e..ab4dda845db9f78d6d41aabf532eeea9da282ec7 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.auth.models import RefreshToken, User +from homeassistant.components.http import current_request from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized @@ -137,6 +138,13 @@ class ActiveConnection: err_message = "Unknown error" log_handler = self.logger.exception - log_handler("Error handling message: %s (%s)", err_message, code) - self.send_message(messages.error_message(msg["id"], code, err_message)) + + if code: + err_message += f" ({code})" + if request := current_request.get(): + err_message += f" from {request.remote}" + if user_agent := request.headers.get("user-agent"): + err_message += f" ({user_agent})" + + log_handler("Error handling message: %s", err_message) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 7336fa1c0d22a31c1de782a65bd29f5136b63598..23c8fddd56ca4547749a1da070768dab39f97793 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -155,7 +155,8 @@ class WebSocketHandler: return self._logger.error( - "Client unable to keep up with pending messages. Stayed over %s for %s seconds", + "Client unable to keep up with pending messages. Stayed over %s for %s seconds. " + "The system's load is too high or an integration is misbehaving", PENDING_MSG_PEAK, PENDING_MSG_PEAK_TIME, ) diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 3ff0f115a04567b47d007e18afd95bc0365f94e9..2767d44032cae16cfb102f012cb3073a32653390 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -76,8 +76,7 @@ def async_setup_bridge( known_light_ids.add(light_id) new_lights.append(WemoLight(coordinator, light)) - if new_lights: - async_add_entities(new_lights) + async_add_entities(new_lights) async_update_lights() config_entry.async_on_unload(coordinator.async_add_listener(async_update_lights)) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index 5486a1927873638962984b2bb810e5ec8297182b..b324ba060ea161db3bd181c782fdafa50d577c60 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -14,8 +14,5 @@ }, "codeowners": ["@esev"], "iot_class": "local_push", - "loggers": ["pywemo"], - "supported_brands": { - "digital_loggers": "Digital Loggers" - } + "loggers": ["pywemo"] } diff --git a/homeassistant/components/whirlpool/translations/nb.json b/homeassistant/components/whirlpool/translations/nb.json index 847c45368fd80b5d35553d5b40161e2990b0e5bd..fc3d4c4023c664df446edc14a414cdf3cea3847c 100644 --- a/homeassistant/components/whirlpool/translations/nb.json +++ b/homeassistant/components/whirlpool/translations/nb.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/whois/manifest.json b/homeassistant/components/whois/manifest.json index 00a2821c8c449f879bfbb90e44cbccd073e35578..104b583ea3a7ee087ded40723b80ef0cf6dd949f 100644 --- a/homeassistant/components/whois/manifest.json +++ b/homeassistant/components/whois/manifest.json @@ -6,5 +6,6 @@ "config_flow": true, "codeowners": ["@frenck"], "iot_class": "cloud_polling", + "integration_type": "service", "loggers": ["whois"] } diff --git a/homeassistant/components/wiz/translations/nb.json b/homeassistant/components/wiz/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/wiz/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 74af6cc0793960817636a14ccea935a55d27f64c..2c68401376575a5e88ddc7988d0e6df64dcc753a 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -270,5 +270,4 @@ def async_update_segments( current_ids.add(segment_id) new_entities.append(WLEDSegmentLight(coordinator, segment_id)) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 2fc00131fac684284f5b013b09eb763655d39d0f..3566349a8537d330710bb7704c7a07e8651240b9 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -7,5 +7,6 @@ "zeroconf": ["_wled._tcp.local."], "codeowners": ["@frenck"], "quality_scale": "platinum", + "integration_type": "device", "iot_class": "local_push" } diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 33b27777c7ecea0f554a499ae8fc59542b57dd01..eb029a07db73c59707ed35cd0a308d9321f0684f 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -1,8 +1,12 @@ """Support for LED numbers.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from functools import partial +from wled import Segment + from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -35,8 +39,20 @@ async def async_setup_entry( update_segments() +@dataclass +class WLEDNumberDescriptionMixin: + """Mixin for WLED number.""" + + value_fn: Callable[[Segment], float | None] + + +@dataclass +class WLEDNumberEntityDescription(NumberEntityDescription, WLEDNumberDescriptionMixin): + """Class describing WLED number entities.""" + + NUMBERS = [ - NumberEntityDescription( + WLEDNumberEntityDescription( key=ATTR_SPEED, name="Speed", icon="mdi:speedometer", @@ -44,14 +60,16 @@ NUMBERS = [ native_step=1, native_min_value=0, native_max_value=255, + value_fn=lambda segment: segment.speed, ), - NumberEntityDescription( + WLEDNumberEntityDescription( key=ATTR_INTENSITY, name="Intensity", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, native_max_value=255, + value_fn=lambda segment: segment.intensity, ), ] @@ -59,11 +77,13 @@ NUMBERS = [ class WLEDNumber(WLEDEntity, NumberEntity): """Defines a WLED speed number.""" + entity_description: WLEDNumberEntityDescription + def __init__( self, coordinator: WLEDDataUpdateCoordinator, segment: int, - description: NumberEntityDescription, + description: WLEDNumberEntityDescription, ) -> None: """Initialize WLED .""" super().__init__(coordinator=coordinator) @@ -92,9 +112,8 @@ class WLEDNumber(WLEDEntity, NumberEntity): @property def native_value(self) -> float | None: """Return the current WLED segment number value.""" - return getattr( - self.coordinator.data.state.segments[self._segment], - self.entity_description.key, + return self.entity_description.value_fn( + self.coordinator.data.state.segments[self._segment] ) @wled_exception_handler @@ -128,5 +147,4 @@ def async_update_segments( for desc in NUMBERS: new_entities.append(WLEDNumber(coordinator, segment_id, desc)) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index 5b0de05370cf959739af2cd6b1ee108a832c878b..badde5515d481e1887d4c91da807a3b891ae44f0 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -195,5 +195,4 @@ def async_update_segments( current_ids.add(segment_id) new_entities.append(WLEDPaletteSelect(coordinator, segment_id)) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 20b5a6187263d498de27b6371652d2bd35537770..9f241756e90ebbe1a8a1526e4065c5bca42bd638 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -215,5 +215,4 @@ def async_update_segments( current_ids.add(segment_id) new_entities.append(WLEDReverseSwitch(coordinator, segment_id)) - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) diff --git a/homeassistant/components/wled/translations/select.pl.json b/homeassistant/components/wled/translations/select.pl.json index 381f2306d260c0f29a1d3a5ff723685619dd5e10..20017c51c4243ce8092de8b952e379a71054b442 100644 --- a/homeassistant/components/wled/translations/select.pl.json +++ b/homeassistant/components/wled/translations/select.pl.json @@ -3,7 +3,7 @@ "wled__live_override": { "0": "wy\u0142.", "1": "w\u0142.", - "2": "Do czasu ponownego uruchomienia urz\u0105dzenia" + "2": "do czasu ponownego uruchomienia urz\u0105dzenia" } } } \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/nb.json b/homeassistant/components/wolflink/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/wolflink/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ws66i/translations/nb.json b/homeassistant/components/ws66i/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/ws66i/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index ac97d502c5561de934ec3d8d6fea4b335de52695..3c262bce82e37c7f713b373eff01a63653aa6c92 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -62,8 +62,7 @@ def async_update_friends( ] new_entities = new_entities + current[xuid] - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) # Process deleted favorites, remove them from Home Assistant for xuid in current_ids - new_ids: diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index 9cba49d1dcbc2917371e3d3d91db5dae0bd00893..77d52719c88751dd5406b70303bb7153aa38232c 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -64,8 +64,7 @@ def async_update_friends( ] new_entities = new_entities + current[xuid] - if new_entities: - async_add_entities(new_entities) + async_add_entities(new_entities) # Process deleted favorites, remove them from Home Assistant for xuid in current_ids - new_ids: diff --git a/homeassistant/components/xbox/translations/id.json b/homeassistant/components/xbox/translations/id.json index 3df7f3ee8f25d7802abd9eb1bf0df780e45a8fad..e59a8a2989fb87e882728037836d92c28adcab29 100644 --- a/homeassistant/components/xbox/translations/id.json +++ b/homeassistant/components/xbox/translations/id.json @@ -16,8 +16,8 @@ }, "issues": { "deprecated_yaml": { - "description": "Proses konfigurasi Xbox di configuration.yaml sedang dihapus di Home Assistant 2022.9. \n\nKredensial Aplikasi OAuth yang Anda dan setelan akses telah diimpor ke antarmuka secara otomatis. Hapus konfigurasi YAML dari file configuration.yaml Anda dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", - "title": "Konfigurasi YAML Xbox dalam proses penghapusan" + "description": "Proses konfigurasi Integrasi Xbox di configuration.yaml sedang dihapus di Home Assistant 2022.9. \n\nKredensial Aplikasi OAuth yang Anda dan setelan akses telah diimpor ke antarmuka secara otomatis. Hapus konfigurasi YAML dari file configuration.yaml Anda dan mulai ulang Home Assistant untuk memperbaiki masalah ini.", + "title": "Konfigurasi YAML Integrasi Xbox dalam proses penghapusan" } } } \ No newline at end of file diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py index 3081f334821ded387fd97dfc88bbcdaade0843d0..07adcbeb5ccf74e59f14b34ec02e1e55e8c2f865 100644 --- a/homeassistant/components/xbox_live/sensor.py +++ b/homeassistant/components/xbox_live/sensor.py @@ -60,8 +60,7 @@ def setup_platform( continue entities.append(XboxSensor(api, xuid, gamercard, interval)) - if entities: - add_entities(entities, True) + add_entities(entities, True) def get_user_gamercard(api, xuid): diff --git a/homeassistant/components/xiaomi_ble/translations/hu.json b/homeassistant/components/xiaomi_ble/translations/hu.json index 044f970038b8c84393fa8cfe48789e7371adad39..fed82381dcbec92c179edc5901ae8c0793a1eb09 100644 --- a/homeassistant/components/xiaomi_ble/translations/hu.json +++ b/homeassistant/components/xiaomi_ble/translations/hu.json @@ -41,7 +41,7 @@ "data": { "address": "Eszk\u00f6z" }, - "description": "V\u00e1lassza ki a be\u00e1ll\u00edtani k\u00edv\u00e1nt eszk\u00f6zt" + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" } } } diff --git a/homeassistant/components/xiaomi_ble/translations/no.json b/homeassistant/components/xiaomi_ble/translations/no.json index ff428d248d1697b8f851813e5d4236b5b2b96aea..46a8158cad9eb35178b5125cf722e9a6ff8b60f5 100644 --- a/homeassistant/components/xiaomi_ble/translations/no.json +++ b/homeassistant/components/xiaomi_ble/translations/no.json @@ -7,7 +7,7 @@ "expected_24_characters": "Forventet en heksadesimal bindingsn\u00f8kkel p\u00e5 24 tegn.", "expected_32_characters": "Forventet en heksadesimal bindingsn\u00f8kkel p\u00e5 32 tegn.", "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "decryption_failed": "Den oppgitte bindingsn\u00f8kkelen fungerte ikke, sensordata kunne ikke dekrypteres. Vennligst sjekk det og pr\u00f8v igjen.", diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 8719319aec8b46c599e9d981a1a693d43fb4402a..d3a407d529e8294cead9b042ed990fd099e9c75a 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -383,10 +383,6 @@ async def async_setup_gateway_entry(hass: HomeAssistant, entry: ConfigEntry) -> assert gateway_id - # For backwards compat - if gateway_id.endswith("-gateway"): - hass.config_entries.async_update_entry(entry, unique_id=entry.data["mac"]) - # Connect to gateway gateway = ConnectXiaomiGateway(hass, entry) try: diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 4e2ba24bc0518ed813dfda7f8377b12799428226..70e6fb5c0b6bedf23d88e4e73bd9d87932002ce0 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME, CONF_TOKEN +from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac @@ -145,18 +145,6 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_cloud() return self.async_show_form(step_id="reauth_confirm") - 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] - self.name = conf.get(CONF_NAME) - self.model = conf.get(CONF_MODEL) - - self.context.update( - {"title_placeholders": {"name": f"YAML import {self.host}"}} - ) - return await self.async_step_connect() - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -250,15 +238,22 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cloud_login_error" except MiCloudAccessDenied: errors["base"] = "cloud_login_error" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception in Miio cloud login") + return self.async_abort(reason="unknown") if errors: return self.async_show_form( step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors ) - devices_raw = await self.hass.async_add_executor_job( - miio_cloud.get_devices, cloud_country - ) + try: + devices_raw = await self.hass.async_add_executor_job( + miio_cloud.get_devices, cloud_country + ) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception in Miio cloud get devices") + return self.async_abort(reason="unknown") if not devices_raw: errors["base"] = "cloud_no_devices" @@ -353,6 +348,9 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except SetupException: if self.model is None: errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception in connect Xiaomi device") + return self.async_abort(reason="unknown") device_info = connect_device_class.device_info @@ -386,8 +384,8 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data[CONF_CLOUD_USERNAME] = self.cloud_username data[CONF_CLOUD_PASSWORD] = self.cloud_password data[CONF_CLOUD_COUNTRY] = self.cloud_country - self.hass.config_entries.async_update_entry(existing_entry, data=data) - await self.hass.config_entries.async_reload(existing_entry.entry_id) + if self.hass.config_entries.async_update_entry(existing_entry, data=data): + await self.hass.config_entries.async_reload(existing_entry.entry_id) return self.async_abort(reason="reauth_successful") if self.name is None: diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index c0711a02a3654058e571163956fa8120038b905b..0c090a58e0254489ce32e53cf1b458058c5342f5 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -4,6 +4,7 @@ from miio.vacuum import ( ROCKROBO_S6, ROCKROBO_S6_MAXV, ROCKROBO_S7, + ROCKROBO_S7_MAXV, ROCKROBO_V1, ) @@ -48,6 +49,8 @@ class SetupException(Exception): # Fan Models MODEL_AIRPURIFIER_4 = "zhimi.airp.mb5" +MODEL_AIRPURIFIER_4_LITE_RMA1 = "zhimi.airpurifier.rma1" +MODEL_AIRPURIFIER_4_LITE_RMB1 = "zhimi.airp.rmb1" MODEL_AIRPURIFIER_4_PRO = "zhimi.airp.vb4" MODEL_AIRPURIFIER_2H = "zhimi.airpurifier.mc2" MODEL_AIRPURIFIER_2S = "zhimi.airpurifier.mc1" @@ -117,6 +120,8 @@ MODELS_PURIFIER_MIOT = [ MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_3H, MODEL_AIRPURIFIER_PROH, + MODEL_AIRPURIFIER_4_LITE_RMA1, + MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4, MODEL_AIRPURIFIER_4_PRO, ] @@ -227,6 +232,7 @@ MODELS_VACUUM = [ ROCKROBO_S6_MAXV, ROCKROBO_S6_PURE, ROCKROBO_S7, + ROCKROBO_S7_MAXV, ROBOROCK_GENERIC, ROCKROBO_GENERIC, ] @@ -238,9 +244,11 @@ MODELS_VACUUM_WITH_MOP = [ ROCKROBO_S6_MAXV, ROCKROBO_S6_PURE, ROCKROBO_S7, + ROCKROBO_S7_MAXV, ] MODELS_VACUUM_WITH_SEPARATE_MOP = [ ROCKROBO_S7, + ROCKROBO_S7_MAXV, ] MODELS_AIR_MONITOR = [ @@ -342,6 +350,10 @@ FEATURE_FLAGS_AIRPURIFIER_MIOT = ( | FEATURE_SET_LED_BRIGHTNESS ) +FEATURE_FLAGS_AIRPURIFIER_4_LITE = ( + FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED_BRIGHTNESS +) + FEATURE_FLAGS_AIRPURIFIER_4 = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index ddbd45bff089a0f14d6f8197536889ddf112e7cd..dbc8c7a66d9c34852e86919278ebb88e2d25fa7e 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -49,6 +49,7 @@ from .const import ( FEATURE_FLAGS_AIRPURIFIER_2S, FEATURE_FLAGS_AIRPURIFIER_3C, FEATURE_FLAGS_AIRPURIFIER_4, + FEATURE_FLAGS_AIRPURIFIER_4_LITE, FEATURE_FLAGS_AIRPURIFIER_MIIO, FEATURE_FLAGS_AIRPURIFIER_MIOT, FEATURE_FLAGS_AIRPURIFIER_PRO, @@ -70,6 +71,8 @@ from .const import ( MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_4, + MODEL_AIRPURIFIER_4_LITE_RMA1, + MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4_PRO, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, @@ -151,6 +154,7 @@ AVAILABLE_ATTRIBUTES_AIRFRESH = { } PRESET_MODES_AIRPURIFIER = ["Auto", "Silent", "Favorite", "Idle"] +PRESET_MODES_AIRPURIFIER_4_LITE = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_MIOT = ["Auto", "Silent", "Favorite", "Fan"] PRESET_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_PRO_V7 = PRESET_MODES_AIRPURIFIER_PRO @@ -424,6 +428,15 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE ) self._speed_count = 3 + elif self._model in [ + MODEL_AIRPURIFIER_4_LITE_RMA1, + MODEL_AIRPURIFIER_4_LITE_RMB1, + ]: + self._device_features = FEATURE_FLAGS_AIRPURIFIER_4_LITE + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_MIOT + self._preset_modes = PRESET_MODES_AIRPURIFIER_4_LITE + self._attr_supported_features = FanEntityFeature.PRESET_MODE + self._speed_count = 1 elif self._model == MODEL_AIRPURIFIER_PRO_V7: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO_V7 self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 0f1a9dd92aa2e04d2d2d6d01829fc20252701efa..4f806c3ed58fe32ec5cd89286b0335de84ba4103 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", "requirements": ["construct==2.10.56", "micloud==0.5", "python-miio==0.5.12"], - "codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"], + "codeowners": ["@rytilahti", "@syssi", "@starkillerOG"], "zeroconf": ["_miio._udp.local."], "iot_class": "local_polling", "loggers": ["micloud", "miio"] diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 15cb7175d91e2147bece4e7beca4cba95bd6052c..7c5439d8d35402830d3172ceaf6f4996cfd53983 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -32,6 +32,7 @@ from .const import ( FEATURE_FLAGS_AIRPURIFIER_2S, FEATURE_FLAGS_AIRPURIFIER_3C, FEATURE_FLAGS_AIRPURIFIER_4, + FEATURE_FLAGS_AIRPURIFIER_4_LITE, FEATURE_FLAGS_AIRPURIFIER_MIIO, FEATURE_FLAGS_AIRPURIFIER_MIOT, FEATURE_FLAGS_AIRPURIFIER_PRO, @@ -65,6 +66,8 @@ from .const import ( MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_4, + MODEL_AIRPURIFIER_4_LITE_RMA1, + MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4_PRO, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, @@ -242,6 +245,8 @@ MODEL_TO_FEATURES_MAP = { MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3, + MODEL_AIRPURIFIER_4_LITE_RMA1: FEATURE_FLAGS_AIRPURIFIER_4_LITE, + MODEL_AIRPURIFIER_4_LITE_RMB1: FEATURE_FLAGS_AIRPURIFIER_4_LITE, MODEL_AIRPURIFIER_4: FEATURE_FLAGS_AIRPURIFIER_4, MODEL_AIRPURIFIER_4_PRO: FEATURE_FLAGS_AIRPURIFIER_4, MODEL_FAN_1C: FEATURE_FLAGS_FAN_1C, diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 69f8dbfad30bd90ed70bbd076afd1f5058815351..118f3cd5c77be77dba5222abc7b0be8e8777f3a8 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -47,6 +47,8 @@ from .const import ( MODEL_AIRHUMIDIFIER_V1, MODEL_AIRPURIFIER_3, MODEL_AIRPURIFIER_3H, + MODEL_AIRPURIFIER_4, + MODEL_AIRPURIFIER_4_PRO, MODEL_AIRPURIFIER_M1, MODEL_AIRPURIFIER_M2, MODEL_AIRPURIFIER_PROH, @@ -72,7 +74,6 @@ class XiaomiMiioSelectDescription(SelectEntityDescription): options_map: dict = field(default_factory=dict) set_method: str = "" set_method_error_message: str = "" - options: tuple = () class AttributeEnumMapping(NamedTuple): @@ -111,6 +112,12 @@ MODEL_TO_ATTR_MAP: dict[str, list] = { MODEL_AIRPURIFIER_3H: [ AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) ], + MODEL_AIRPURIFIER_4: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) + ], + MODEL_AIRPURIFIER_4_PRO: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) + ], MODEL_AIRPURIFIER_M1: [ AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierLedBrightness) ], @@ -142,7 +149,7 @@ SELECTOR_TYPES = ( set_method_error_message="Setting the display orientation failed.", icon="mdi:tablet", device_class="xiaomi_miio__display_orientation", - options=("forward", "left", "right"), + options=["forward", "left", "right"], entity_category=EntityCategory.CONFIG, ), XiaomiMiioSelectDescription( @@ -153,7 +160,7 @@ SELECTOR_TYPES = ( set_method_error_message="Setting the led brightness failed.", icon="mdi:brightness-6", device_class="xiaomi_miio__led_brightness", - options=("bright", "dim", "off"), + options=["bright", "dim", "off"], entity_category=EntityCategory.CONFIG, ), XiaomiMiioSelectDescription( @@ -164,7 +171,7 @@ SELECTOR_TYPES = ( set_method_error_message="Setting the ptc level failed.", icon="mdi:fire-circle", device_class="xiaomi_miio__ptc_level", - options=("low", "medium", "high"), + options=["low", "medium", "high"], entity_category=EntityCategory.CONFIG, ), ) @@ -212,7 +219,6 @@ class XiaomiSelector(XiaomiCoordinatedMiioEntity, SelectEntity): def __init__(self, device, entry, unique_id, coordinator, description): """Initialize the generic Xiaomi attribute selector.""" super().__init__(device, entry, unique_id, coordinator) - self._attr_options = list(description.options) self.entity_description = description diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index c94e0e371fb2be49bc5173fe785962a41fe4dc2f..56938a4bd344cb7b94f023927c37ace5d3a922ac 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -63,6 +63,8 @@ from .const import ( MODEL_AIRHUMIDIFIER_CB1, MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_4, + MODEL_AIRPURIFIER_4_LITE_RMA1, + MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4_PRO, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, @@ -411,6 +413,16 @@ PURIFIER_MIOT_SENSORS = ( ATTR_TEMPERATURE, ATTR_USE_TIME, ) +PURIFIER_4_LITE_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_LEFT_TIME, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_TEMPERATURE, + ATTR_USE_TIME, +) PURIFIER_4_SENSORS = ( ATTR_FILTER_LIFE_REMAINING, ATTR_FILTER_LEFT_TIME, @@ -528,6 +540,8 @@ MODEL_TO_SENSORS_MAP: dict[str, tuple[str, ...]] = { MODEL_AIRHUMIDIFIER_CA1: HUMIDIFIER_CA1_CB1_SENSORS, MODEL_AIRHUMIDIFIER_CB1: HUMIDIFIER_CA1_CB1_SENSORS, MODEL_AIRPURIFIER_3C: PURIFIER_3C_SENSORS, + MODEL_AIRPURIFIER_4_LITE_RMA1: PURIFIER_4_LITE_SENSORS, + MODEL_AIRPURIFIER_4_LITE_RMB1: PURIFIER_4_LITE_SENSORS, MODEL_AIRPURIFIER_4: PURIFIER_4_SENSORS, MODEL_AIRPURIFIER_4_PRO: PURIFIER_4_PRO_SENSORS, MODEL_AIRPURIFIER_PRO: PURIFIER_PRO_SENSORS, diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index e359f54cc5a1f6730edfe8bc218a536321c8b2a1..ea9e1712697e8de4630be44ad387cbebad498374 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -5,7 +5,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "incomplete_info": "Incomplete information to setup device, no host or token supplied.", - "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Miio." + "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Miio.", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index dbe783c6a8706d2d192471e31b54a5aa21443543..2f45ba0adcaf67a016b6d219227a3c1b6404c05f 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -46,6 +46,7 @@ from .const import ( FEATURE_FLAGS_AIRPURIFIER_2S, FEATURE_FLAGS_AIRPURIFIER_3C, FEATURE_FLAGS_AIRPURIFIER_4, + FEATURE_FLAGS_AIRPURIFIER_4_LITE, FEATURE_FLAGS_AIRPURIFIER_MIIO, FEATURE_FLAGS_AIRPURIFIER_MIOT, FEATURE_FLAGS_AIRPURIFIER_PRO, @@ -82,6 +83,8 @@ from .const import ( MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_4, + MODEL_AIRPURIFIER_4_LITE_RMA1, + MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4_PRO, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, @@ -197,6 +200,8 @@ MODEL_TO_FEATURES_MAP = { MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1, MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3, + MODEL_AIRPURIFIER_4_LITE_RMA1: FEATURE_FLAGS_AIRPURIFIER_4_LITE, + MODEL_AIRPURIFIER_4_LITE_RMB1: FEATURE_FLAGS_AIRPURIFIER_4_LITE, MODEL_AIRPURIFIER_4: FEATURE_FLAGS_AIRPURIFIER_4, MODEL_AIRPURIFIER_4_PRO: FEATURE_FLAGS_AIRPURIFIER_4, MODEL_FAN_1C: FEATURE_FLAGS_FAN_1C, diff --git a/homeassistant/components/xiaomi_miio/translations/bg.json b/homeassistant/components/xiaomi_miio/translations/bg.json index 2339d5bc8306a39435c30ff81bbd936db4ee6835..2ad6a9dda2667b5a2c869bea47f27dd8e30447d5 100644 --- a/homeassistant/components/xiaomi_miio/translations/bg.json +++ b/homeassistant/components/xiaomi_miio/translations/bg.json @@ -2,7 +2,8 @@ "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", - "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" + "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", + "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\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" diff --git a/homeassistant/components/xiaomi_miio/translations/ca.json b/homeassistant/components/xiaomi_miio/translations/ca.json index 614238a54b4b65154a6463e264df404173073d96..ff1297080c3d6c49a47d267ab3a8e945bdd2719a 100644 --- a/homeassistant/components/xiaomi_miio/translations/ca.json +++ b/homeassistant/components/xiaomi_miio/translations/ca.json @@ -5,7 +5,8 @@ "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", "incomplete_info": "Informaci\u00f3 incompleta per configurar el dispositiu, no s'ha proporcionat cap amfitri\u00f3 o token.", "not_xiaomi_miio": "Xiaomi Miio encara no \u00e9s compatible amb el dispositiu.", - "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", + "unknown": "Error inesperat" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", diff --git a/homeassistant/components/xiaomi_miio/translations/de.json b/homeassistant/components/xiaomi_miio/translations/de.json index 70a630f90f39d8f39b1904000140282493484d98..aa5ad47dd4cfbf404758c7f1cdcd52ad1b4acafc 100644 --- a/homeassistant/components/xiaomi_miio/translations/de.json +++ b/homeassistant/components/xiaomi_miio/translations/de.json @@ -5,7 +5,8 @@ "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "incomplete_info": "Unvollst\u00e4ndige Informationen zur Einrichtung des Ger\u00e4ts, kein Host oder Token geliefert.", "not_xiaomi_miio": "Ger\u00e4t wird (noch) nicht von Xiaomi Miio unterst\u00fctzt.", - "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + "reauth_successful": "Die erneute Authentifizierung war erfolgreich", + "unknown": "Unerwarteter Fehler" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/xiaomi_miio/translations/en.json b/homeassistant/components/xiaomi_miio/translations/en.json index c37be0a7f741760ee77b83c9e4b27cd62858af16..d24509e0e255d60869a9ef409adcdd056f781f7a 100644 --- a/homeassistant/components/xiaomi_miio/translations/en.json +++ b/homeassistant/components/xiaomi_miio/translations/en.json @@ -5,7 +5,8 @@ "already_in_progress": "Configuration flow is already in progress", "incomplete_info": "Incomplete information to setup device, no host or token supplied.", "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Miio.", - "reauth_successful": "Re-authentication was successful" + "reauth_successful": "Re-authentication was successful", + "unknown": "Unexpected error" }, "error": { "cannot_connect": "Failed to connect", diff --git a/homeassistant/components/xiaomi_miio/translations/es.json b/homeassistant/components/xiaomi_miio/translations/es.json index 68bdda15a22a603c5559e78eaf5206a99d1fb929..3173e888df69855e50052f7ba1f77375b2e2fe1a 100644 --- a/homeassistant/components/xiaomi_miio/translations/es.json +++ b/homeassistant/components/xiaomi_miio/translations/es.json @@ -5,7 +5,8 @@ "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en curso", "incomplete_info": "Informaci\u00f3n incompleta para configurar el dispositivo, no se proporcion\u00f3 host ni token.", "not_xiaomi_miio": "El dispositivo no es (todav\u00eda) compatible con Xiaomi Miio.", - "reauth_successful": "La autenticaci\u00f3n se volvi\u00f3 a realizar correctamente" + "reauth_successful": "La autenticaci\u00f3n se volvi\u00f3 a realizar correctamente", + "unknown": "Error inesperado" }, "error": { "cannot_connect": "No se pudo conectar", diff --git a/homeassistant/components/xiaomi_miio/translations/et.json b/homeassistant/components/xiaomi_miio/translations/et.json index fef6a73622a754404c98c30dd2a6860dd36a46eb..bfb9de5077e97fa044acaf0bb0f05a79da2088f2 100644 --- a/homeassistant/components/xiaomi_miio/translations/et.json +++ b/homeassistant/components/xiaomi_miio/translations/et.json @@ -5,7 +5,8 @@ "already_in_progress": "Seadistamine on juba k\u00e4imas", "incomplete_info": "Puudulik seadistusteave, hosti v\u00f5i p\u00e4\u00e4suluba pole esitatud.", "not_xiaomi_miio": "Seade ei ole (veel) Xiaomi Miio poolt toetatud.", - "reauth_successful": "Taastuvastamine \u00f5nnestus" + "reauth_successful": "Taastuvastamine \u00f5nnestus", + "unknown": "Ootamatu t\u00f5rge" }, "error": { "cannot_connect": "\u00dchendus nurjus", diff --git a/homeassistant/components/xiaomi_miio/translations/fr.json b/homeassistant/components/xiaomi_miio/translations/fr.json index 91c6b7458160c59c340c77d33fd1fc213ed57b89..1ed0609da1b7e17099705924794bc1236c61e517 100644 --- a/homeassistant/components/xiaomi_miio/translations/fr.json +++ b/homeassistant/components/xiaomi_miio/translations/fr.json @@ -5,7 +5,8 @@ "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "incomplete_info": "Informations incompl\u00e8tes pour configurer l'appareil, aucun h\u00f4te ou jeton fourni.", "not_xiaomi_miio": "L'appareil n'est pas (encore) pris en charge par Xiaomi Miio.", - "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi", + "unknown": "Erreur inattendue" }, "error": { "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/xiaomi_miio/translations/he.json b/homeassistant/components/xiaomi_miio/translations/he.json index 1a21bd9840a57142b80a1d45aa14420acf7269ad..07cdec38236e9472aeb32db1cd6faebe65d87fd7 100644 --- a/homeassistant/components/xiaomi_miio/translations/he.json +++ b/homeassistant/components/xiaomi_miio/translations/he.json @@ -5,7 +5,8 @@ "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", "incomplete_info": "\u05de\u05d9\u05d3\u05e2 \u05dc\u05d0 \u05e9\u05dc\u05dd \u05dc\u05d4\u05ea\u05e7\u05e0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df, \u05dc\u05d0 \u05e1\u05d5\u05e4\u05e7\u05d5 \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05d0\u05e1\u05d9\u05de\u05d5\u05df.", "not_xiaomi_miio": "\u05d4\u05d4\u05ea\u05e7\u05df \u05d0\u05d9\u05e0\u05d5 \u05e0\u05ea\u05de\u05da (\u05e2\u05d3\u05d9\u05d9\u05df) \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5.", - "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", diff --git a/homeassistant/components/xiaomi_miio/translations/hu.json b/homeassistant/components/xiaomi_miio/translations/hu.json index a7536283240091b8c9517ece0183b8c889b39f37..874483fffb3457da2a18971d3487574ceaadaaae 100644 --- a/homeassistant/components/xiaomi_miio/translations/hu.json +++ b/homeassistant/components/xiaomi_miio/translations/hu.json @@ -5,7 +5,8 @@ "already_in_progress": "A be\u00e1ll\u00edt\u00e1si folyamat m\u00e1r el lett kezdve", "incomplete_info": "Az eszk\u00f6z be\u00e1ll\u00edt\u00e1s\u00e1hoz sz\u00fcks\u00e9ges inform\u00e1ci\u00f3k hi\u00e1nyosak, nincs megadva \u00e1llom\u00e1s vagy token.", "not_xiaomi_miio": "Az eszk\u00f6zt (m\u00e9g) nem t\u00e1mogatja a Xiaomi Miio integr\u00e1ci\u00f3.", - "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt.", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", diff --git a/homeassistant/components/xiaomi_miio/translations/it.json b/homeassistant/components/xiaomi_miio/translations/it.json index 51b5f108751493a8070376718e68cae74c394878..239620dfdc6d5836ad4663362c125f2b0285c9f0 100644 --- a/homeassistant/components/xiaomi_miio/translations/it.json +++ b/homeassistant/components/xiaomi_miio/translations/it.json @@ -5,7 +5,8 @@ "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", "incomplete_info": "Informazioni incomplete per configurare il dispositivo, nessun host o token fornito.", "not_xiaomi_miio": "Il dispositivo non \u00e8 (ancora) supportato da Xiaomi Miio.", - "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", + "unknown": "Errore imprevisto" }, "error": { "cannot_connect": "Impossibile connettersi", diff --git a/homeassistant/components/xiaomi_miio/translations/nl.json b/homeassistant/components/xiaomi_miio/translations/nl.json index cc0778398083178024f44d8309d61c2431715adc..07671e1802ee880e8fe438ad1045afaceef7ea01 100644 --- a/homeassistant/components/xiaomi_miio/translations/nl.json +++ b/homeassistant/components/xiaomi_miio/translations/nl.json @@ -5,7 +5,8 @@ "already_in_progress": "De configuratie is momenteel al bezig", "incomplete_info": "Onvolledige informatie voor het instellen van het apparaat, geen host of token opgegeven.", "not_xiaomi_miio": "Apparaat wordt (nog) niet ondersteund door Xiaomi Miio.", - "reauth_successful": "Herauthenticatie geslaagd" + "reauth_successful": "Herauthenticatie geslaagd", + "unknown": "Onverwachte fout" }, "error": { "cannot_connect": "Kan geen verbinding maken", diff --git a/homeassistant/components/xiaomi_miio/translations/no.json b/homeassistant/components/xiaomi_miio/translations/no.json index 3d831df207c8aa935a786ea45e5d707a79c08d7d..8f06bee02233ce5ebd5db7601f1e69dae8d89f4c 100644 --- a/homeassistant/components/xiaomi_miio/translations/no.json +++ b/homeassistant/components/xiaomi_miio/translations/no.json @@ -5,7 +5,8 @@ "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", "incomplete_info": "Ufullstendig informasjon til installasjonsenheten, ingen vert eller token leveres.", "not_xiaomi_miio": "Enheten st\u00f8ttes (enn\u00e5) ikke av Xiaomi Miio.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket", + "unknown": "Uventet feil" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/xiaomi_miio/translations/pl.json b/homeassistant/components/xiaomi_miio/translations/pl.json index d0f3cc9a4a83e508b456c17a3b19d37485fca6ea..72b031bd6067f72772fb850bfe5f5e709bd5df62 100644 --- a/homeassistant/components/xiaomi_miio/translations/pl.json +++ b/homeassistant/components/xiaomi_miio/translations/pl.json @@ -5,7 +5,8 @@ "already_in_progress": "Konfiguracja jest ju\u017c w toku", "incomplete_info": "Niepe\u0142ne informacje do skonfigurowania urz\u0105dzenia, brak nazwy hosta, IP lub tokena.", "not_xiaomi_miio": "Urz\u0105dzenie nie jest (jeszcze) obs\u0142ugiwane przez Xiaomi Miio.", - "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", + "unknown": "Nieoczekiwany b\u0142\u0105d" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", diff --git a/homeassistant/components/xiaomi_miio/translations/pt-BR.json b/homeassistant/components/xiaomi_miio/translations/pt-BR.json index ce67173f9b283987ae14d027eba460aaf66bae9a..f12c3637b7eabf4d80df1be8faf9b092ac8249b3 100644 --- a/homeassistant/components/xiaomi_miio/translations/pt-BR.json +++ b/homeassistant/components/xiaomi_miio/translations/pt-BR.json @@ -5,7 +5,8 @@ "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", "incomplete_info": "Informa\u00e7\u00f5es incompletas para configurar o dispositivo, nenhum host ou token fornecido.", "not_xiaomi_miio": "O dispositivo (ainda) n\u00e3o \u00e9 suportado pelo Xiaomi Miio.", - "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" + "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida", + "unknown": "Erro inesperado" }, "error": { "cannot_connect": "Falha ao conectar", diff --git a/homeassistant/components/xiaomi_miio/translations/ru.json b/homeassistant/components/xiaomi_miio/translations/ru.json index 1432666cb44852eba5f56bb392c0c80a280d404f..256cf75bbd268a5873594270d3c6893ff70db3f2 100644 --- a/homeassistant/components/xiaomi_miio/translations/ru.json +++ b/homeassistant/components/xiaomi_miio/translations/ru.json @@ -5,7 +5,8 @@ "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", "incomplete_info": "\u041d\u0435\u043f\u043e\u043b\u043d\u0430\u044f \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u043d\u0435 \u0443\u043a\u0430\u0437\u0430\u043d \u0445\u043e\u0441\u0442 \u0438\u043b\u0438 \u0442\u043e\u043a\u0435\u043d.", "not_xiaomi_miio": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e (\u043f\u043e\u043a\u0430) \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f Xiaomi Miio.", - "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", diff --git a/homeassistant/components/xiaomi_miio/translations/select.pl.json b/homeassistant/components/xiaomi_miio/translations/select.pl.json index 92a1539b9cee80f91eeb308a1ac4cc9b596fde62..09197cc33f124f8eff19e23405bd4dc8edc48fd4 100644 --- a/homeassistant/components/xiaomi_miio/translations/select.pl.json +++ b/homeassistant/components/xiaomi_miio/translations/select.pl.json @@ -1,9 +1,9 @@ { "state": { "xiaomi_miio__display_orientation": { - "forward": "Do przodu", - "left": "W lewo", - "right": "W prawo" + "forward": "do przodu", + "left": "w lewo", + "right": "w prawo" }, "xiaomi_miio__led_brightness": { "bright": "jasne", @@ -11,9 +11,9 @@ "off": "wy\u0142\u0105czone" }, "xiaomi_miio__ptc_level": { - "high": "Wysoki", - "low": "Niski", - "medium": "\u015aredni" + "high": "wysoki", + "low": "niski", + "medium": "\u015bredni" } } } \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/zh-Hant.json b/homeassistant/components/xiaomi_miio/translations/zh-Hant.json index e38567ea37cd27c583fa4ae4c3c3a5d545d636b1..9c3158ac2e9f1fa1e864016115ee3ad3ea108dc4 100644 --- a/homeassistant/components/xiaomi_miio/translations/zh-Hant.json +++ b/homeassistant/components/xiaomi_miio/translations/zh-Hant.json @@ -5,7 +5,8 @@ "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", "incomplete_info": "\u6240\u63d0\u4f9b\u4e4b\u88dd\u7f6e\u8cc7\u8a0a\u4e0d\u5b8c\u6574\u3001\u7121\u4e3b\u6a5f\u7aef\u6216\u6b0a\u6756\uff0c\u7121\u6cd5\u8a2d\u5b9a\u88dd\u7f6e\u3002", "not_xiaomi_miio": "\u5c0f\u7c73 Miio \uff08\u5c1a\uff09\u4e0d\u652f\u63f4\u8a72\u88dd\u7f6e\u3002", - "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 6b4faf32458534753c1e3c2eb72f03284c30f0f4..28d60d317e00ff8e0e393193de6d17707055afd5 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -159,6 +159,9 @@ async def async_send_message( # noqa: C901 async def start(self, event): """Start the communication and sends the message.""" + if room: + _LOGGER.debug("Joining room %s", room) + await self.plugin["xep_0045"].join_muc_wait(room, sender, seconds=0) # Sending image and message independently from each other if data: await self.send_file(timeout=timeout) @@ -173,9 +176,6 @@ async def async_send_message( # noqa: C901 Send XMPP file message using OOB (XEP_0066) and HTTP Upload (XEP_0363) """ - if room: - self.plugin["xep_0045"].join_muc(room, sender) - try: # Uploading with XEP_0363 _LOGGER.debug("Timeout set to %ss", timeout) @@ -335,8 +335,7 @@ async def async_send_message( # noqa: C901 """Send a text only message to a room or a recipient.""" try: if room: - _LOGGER.debug("Joining room %s", room) - self.plugin["xep_0045"].join_muc(room, sender) + _LOGGER.debug("Sending message to room %s", room) self.send_message(mto=room, mbody=message, mtype="groupchat") else: for recipient in recipients: diff --git a/homeassistant/components/yale_smart_alarm/translations/no.json b/homeassistant/components/yale_smart_alarm/translations/no.json index 579f61f2d71de7e3611ec2a8cdc90ebfbd228d1a..8299c80ebc2c4ee2bddb4ee8b76e03cf84fef391 100644 --- a/homeassistant/components/yale_smart_alarm/translations/no.json +++ b/homeassistant/components/yale_smart_alarm/translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index 6073bf7a0322c5062e2945f56a46f39a4390359f..7a2b3146265f4db63b599e4950b3d006cc55c862 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -94,6 +94,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.title, push_lock ) + @callback + def _async_device_unavailable( + _service_info: bluetooth.BluetoothServiceInfoBleak, + ) -> None: + """Handle device not longer being seen by the bluetooth stack.""" + push_lock.reset_advertisement_state() + + entry.async_on_unload( + bluetooth.async_track_unavailable( + hass, _async_device_unavailable, push_lock.address + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index c70669f7cc61861a75890acb70d6b394078312a0..b43ce18a7e9d763a0fdc3f0770bc657a8cd545b7 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -3,7 +3,7 @@ "name": "Yale Access Bluetooth", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", - "requirements": ["yalexs-ble==1.9.2"], + "requirements": ["yalexs-ble==1.9.5"], "dependencies": ["bluetooth"], "codeowners": ["@bdraco"], "bluetooth": [ @@ -12,8 +12,5 @@ "service_uuid": "0000fe24-0000-1000-8000-00805f9b34fb" } ], - "iot_class": "local_push", - "supported_brands": { - "august_ble": "August Bluetooth" - } + "iot_class": "local_push" } diff --git a/homeassistant/components/yalexs_ble/translations/he.json b/homeassistant/components/yalexs_ble/translations/he.json new file mode 100644 index 0000000000000000000000000000000000000000..a447b36c3ec6081858861f751bc9832ffc3814f4 --- /dev/null +++ b/homeassistant/components/yalexs_ble/translations/he.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\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", + "invalid_key_format": "\u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05dc\u05d0 \u05de\u05e7\u05d5\u05d5\u05df \u05d7\u05d9\u05d9\u05d1 \u05dc\u05d4\u05d9\u05d5\u05ea \u05de\u05d7\u05e8\u05d5\u05d6\u05ea \u05d4\u05e7\u05e1\u05d3\u05e6\u05d9\u05de\u05dc\u05d9\u05ea \u05e9\u05dc 32 \u05d1\u05ea\u05d9\u05dd.", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "flow_title": "{name}" + } +} \ No newline at end of file diff --git a/homeassistant/components/yalexs_ble/translations/nb.json b/homeassistant/components/yalexs_ble/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..a22f7eef3d645972ed7c1337a8c52a9b29cfa58f --- /dev/null +++ b/homeassistant/components/yalexs_ble/translations/nb.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yalexs_ble/translations/pl.json b/homeassistant/components/yalexs_ble/translations/pl.json index 2d32834337c50b8d5f7ee8ca19c597c527eaf25d..6017fb86ffbc3dc9dca237e2b730f27ee8967179 100644 --- a/homeassistant/components/yalexs_ble/translations/pl.json +++ b/homeassistant/components/yalexs_ble/translations/pl.json @@ -24,7 +24,7 @@ "key": "Klucz offline (32-bajtowy ci\u0105g szesnastkowy)", "slot": "Slot klucza offline (liczba ca\u0142kowita od 0 do 255)" }, - "description": "Sprawd\u017a dokumentacj\u0119 na {docs_url}, aby dowiedzie\u0107 si\u0119, jak znale\u017a\u0107 klucz offline." + "description": "Sprawd\u017a dokumentacj\u0119, aby dowiedzie\u0107 si\u0119, jak znale\u017a\u0107 klucz offline." } } } diff --git a/homeassistant/components/yamaha_musiccast/translations/select.pl.json b/homeassistant/components/yamaha_musiccast/translations/select.pl.json index a6e9bde7c4f6066162b229bdbe6bee2060fbc1a0..ee42d8a74fbd256736d03e6f885537229a8e2e9d 100644 --- a/homeassistant/components/yamaha_musiccast/translations/select.pl.json +++ b/homeassistant/components/yamaha_musiccast/translations/select.pl.json @@ -1,38 +1,38 @@ { "state": { "yamaha_musiccast__dimmer": { - "auto": "Automatyczny" + "auto": "automatyczny" }, "yamaha_musiccast__zone_equalizer_mode": { - "auto": "Automatycznie", - "bypass": "Pomijanie", - "manual": "R\u0119cznie" + "auto": "automatycznie", + "bypass": "pomijanie", + "manual": "r\u0119cznie" }, "yamaha_musiccast__zone_link_audio_delay": { - "audio_sync": "Synchronizacja d\u017awi\u0119ku", - "audio_sync_off": "Synchronizacja d\u017awi\u0119ku wy\u0142\u0105czona", - "audio_sync_on": "Synchronizacja d\u017awi\u0119ku w\u0142\u0105czona", - "balanced": "Zr\u00f3wnowa\u017cone", - "lip_sync": "Synchronizacja ust" + "audio_sync": "synchronizacja d\u017awi\u0119ku", + "audio_sync_off": "synchronizacja d\u017awi\u0119ku wy\u0142\u0105czona", + "audio_sync_on": "synchronizacja d\u017awi\u0119ku w\u0142\u0105czona", + "balanced": "zr\u00f3wnowa\u017cone", + "lip_sync": "synchronizacja ust" }, "yamaha_musiccast__zone_link_audio_quality": { - "compressed": "Skompresowane", - "uncompressed": "Nieskompresowane" + "compressed": "skompresowane", + "uncompressed": "nieskompresowane" }, "yamaha_musiccast__zone_link_control": { - "speed": "Pr\u0119dko\u015b\u0107", - "stability": "Stabilno\u015b\u0107", - "standard": "Normalnie" + "speed": "pr\u0119dko\u015b\u0107", + "stability": "stabilno\u015b\u0107", + "standard": "normalnie" }, "yamaha_musiccast__zone_sleep": { "120 min": "120 minut", "30 min": "30 minut", "60 min": "60 minut", "90 min": "90 minut", - "off": "Wy\u0142\u0105czone" + "off": "wy\u0142\u0105czone" }, "yamaha_musiccast__zone_surr_decoder_type": { - "auto": "Automatycznie", + "auto": "automatycznie", "dolby_pl": "Dolby ProLogic", "dolby_pl2x_game": "Dolby ProLogic 2x (Gra)", "dolby_pl2x_movie": "Dolby ProLogic 2x (Film)", @@ -41,12 +41,12 @@ "dts_neo6_cinema": "DTS Neo:6 (Kino)", "dts_neo6_music": "DTS Neo:6 (Muzyka)", "dts_neural_x": "DTS Neural:X", - "toggle": "Prze\u0142\u0105cz" + "toggle": "prze\u0142\u0105cz" }, "yamaha_musiccast__zone_tone_control_mode": { - "auto": "Automatyczna", - "bypass": "Pomijanie", - "manual": "R\u0119czna" + "auto": "automatyczna", + "bypass": "pomijanie", + "manual": "r\u0119czna" } } } \ No newline at end of file diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index 3fdca47ef02f6b34bee18fd873f479ba8963eff3..b7a846bbc67bcedf0319aa82ff53f98cfb64deb1 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv @@ -24,7 +24,6 @@ _LOGGER = logging.getLogger(__name__) STOP_NAME = "stop_name" USER_AGENT = "Home Assistant" -ATTRIBUTION = "Data provided by maps.yandex.ru" CONF_STOP_ID = "stop_id" CONF_ROUTE = "routes" @@ -70,6 +69,8 @@ async def async_setup_platform( class DiscoverYandexTransport(SensorEntity): """Implementation of yandex_transport sensor.""" + _attr_attribution = "Data provided by maps.yandex.ru" + def __init__(self, requester: YandexMapsRequester, stop_id, routes, name): """Initialize sensor.""" self.requester = requester @@ -138,7 +139,7 @@ class DiscoverYandexTransport(SensorEntity): attrs[route] = [] attrs[route].append(departure["text"]) attrs[STOP_NAME] = stop_name - attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + if closer_time is None: self._state = None else: diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 1032ef0d2e5a82aa619086232995ab00b9080f41..16fe2ae7700ec0af57337924b63bc2fc5e7a4eb7 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.2"], + "requirements": ["yeelight==0.7.10", "async-upnp-client==0.32.1"], "codeowners": ["@zewelor", "@shenxn", "@starkillerOG", "@alexyao2015"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index 4c0b0f693105869013c7140c16909b50e80a7f4b..7a0d409b434940e81d5cf096d2d661380e2402c0 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -76,7 +76,7 @@ class YeelightScanner: self._listeners.append( SsdpSearchListener( async_callback=self._async_process_entry, - service_type=SSDP_ST, + search_target=SSDP_ST, target=SSDP_TARGET, source=source, async_connect_callback=_wrap_async_connected_idx(idx), diff --git a/homeassistant/components/yi/manifest.json b/homeassistant/components/yi/manifest.json index d0560ff13f5df44df390cb4d581af70c2d408b62..10f2e4e3d94574f8d803743956323cea5111a7b9 100644 --- a/homeassistant/components/yi/manifest.json +++ b/homeassistant/components/yi/manifest.json @@ -6,5 +6,6 @@ "dependencies": ["ffmpeg"], "codeowners": ["@bachya"], "iot_class": "local_polling", - "loggers": ["aioftp"] + "loggers": ["aioftp"], + "integration_type": "device" } diff --git a/homeassistant/components/yolink/translations/no.json b/homeassistant/components/yolink/translations/no.json index b5e26ac910d37e65d46f2216b71a607819ef4484..2482a294ce1629deb9fe65d2b8cb74c91ec2187d 100644 --- a/homeassistant/components/yolink/translations/no.json +++ b/homeassistant/components/yolink/translations/no.json @@ -7,7 +7,7 @@ "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})", "oauth_error": "Mottatt ugyldige token data.", - "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + "reauth_successful": "Re-autentisering var vellykket" }, "create_entry": { "default": "Vellykket godkjenning" diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 19e9c635dce0179845b51c90b7d764a575d8d6e5..53ffb22393917a4a36ca028f24258f862ee916c1 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -39,13 +39,13 @@ async def async_setup_entry( async_add_entities( [ GasSensor(coordinator, device), - PowerMeterSensor( + EnergyMeterSensor( coordinator, device, "low", SensorStateClass.TOTAL_INCREASING ), - PowerMeterSensor( + EnergyMeterSensor( coordinator, device, "high", SensorStateClass.TOTAL_INCREASING ), - PowerMeterSensor(coordinator, device, "total", SensorStateClass.TOTAL), + EnergyMeterSensor(coordinator, device, "total", SensorStateClass.TOTAL), CurrentPowerSensor(coordinator, device), DeliveryMeterSensor(coordinator, device, "low"), DeliveryMeterSensor(coordinator, device, "high"), @@ -68,10 +68,6 @@ class YoulessBaseSensor(CoordinatorEntity, SensorEntity): ) -> None: """Create the sensor.""" super().__init__(coordinator) - self._device = device - self._device_group = device_group - self._sensor_id = sensor_id - self._attr_unique_id = f"{DOMAIN}_{device}_{sensor_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{device}_{device_group}")}, @@ -149,10 +145,10 @@ class DeliveryMeterSensor(YoulessBaseSensor): ) -> None: """Instantiate a delivery meter sensor.""" super().__init__( - coordinator, device, "delivery", "Power delivery", f"delivery_{dev_type}" + coordinator, device, "delivery", "Energy delivery", f"delivery_{dev_type}" ) self._type = dev_type - self._attr_name = f"Power delivery {dev_type}" + self._attr_name = f"Energy delivery {dev_type}" @property def get_sensor(self) -> YoulessSensor | None: @@ -163,7 +159,7 @@ class DeliveryMeterSensor(YoulessBaseSensor): return getattr(self.coordinator.data.delivery_meter, f"_{self._type}", None) -class PowerMeterSensor(YoulessBaseSensor): +class EnergyMeterSensor(YoulessBaseSensor): """The Youless low meter value sensor.""" _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR @@ -177,13 +173,13 @@ class PowerMeterSensor(YoulessBaseSensor): dev_type: str, state_class: SensorStateClass, ) -> None: - """Instantiate a power meter sensor.""" + """Instantiate a energy meter sensor.""" super().__init__( - coordinator, device, "power", "Power usage", f"power_{dev_type}" + coordinator, device, "power", "Energy usage", f"power_{dev_type}" ) self._device = device self._type = dev_type - self._attr_name = f"Power {dev_type}" + self._attr_name = f"Energy {dev_type}" self._attr_state_class = state_class @property diff --git a/homeassistant/components/zamg/__init__.py b/homeassistant/components/zamg/__init__.py index a0f80956d98ec5cf2e624ca2b1d98e94279e4bf1..67fe7521f951d1f9d87c5a98378372feb2162fdd 100644 --- a/homeassistant/components/zamg/__init__.py +++ b/homeassistant/components/zamg/__init__.py @@ -1 +1,33 @@ """The zamg component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import CONF_STATION_ID, DOMAIN +from .coordinator import ZamgDataUpdateCoordinator + +PLATFORMS = (Platform.WEATHER, Platform.SENSOR) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Zamg from config entry.""" + coordinator = ZamgDataUpdateCoordinator(hass, entry=entry) + station_id = entry.data[CONF_STATION_ID] + coordinator.zamg.set_default_station(station_id) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + # Set up all platforms for this device/entry. + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload ZAMG 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/zamg/config_flow.py b/homeassistant/components/zamg/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..43434b1e8bdcefa525b2749a4945a29760851338 --- /dev/null +++ b/homeassistant/components/zamg/config_flow.py @@ -0,0 +1,136 @@ +"""Config Flow for zamg the Austrian "Zentralanstalt für Meteorologie und Geodynamik" integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from zamg import ZamgData + +from homeassistant import config_entries +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import CONF_STATION_ID, DOMAIN, LOGGER + + +class ZamgConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for zamg integration.""" + + VERSION = 1 + + _client: ZamgData | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, Any] = {} + + if self._client is None: + self._client = ZamgData() + self._client.session = async_get_clientsession(self.hass) + + if user_input is None: + closest_station_id = await self._client.closest_station( + self.hass.config.latitude, + self.hass.config.longitude, + ) + LOGGER.debug("config_flow: closest station = %s", str(closest_station_id)) + stations = await self._client.zamg_stations() + user_input = {} + + schema = vol.Schema( + { + vol.Required( + CONF_STATION_ID, default=int(closest_station_id) + ): vol.In( + { + int(station): f"{stations[station][2]} ({station})" + for station in stations + } + ) + } + ) + return self.async_show_form(step_id="user", data_schema=schema) + + station_id = str(user_input[CONF_STATION_ID]) + + # Check if already configured + await self.async_set_unique_id(station_id) + self._abort_if_unique_id_configured() + + try: + self._client.set_default_station(station_id) + await self._client.update() + except (ValueError, TypeError) as err: + LOGGER.error("Config_flow: Received error from ZAMG: %s", err) + errors["base"] = "cannot_connect" + return self.async_abort( + reason="cannot_connect", description_placeholders=errors + ) + + return self.async_create_entry( + title=user_input.get(CONF_NAME) or self._client.get_station_name, + data={CONF_STATION_ID: station_id}, + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Handle ZAMG configuration import.""" + station_id = str(config.get(CONF_STATION_ID)) + station_name = config.get(CONF_NAME) + # create issue every time after restart + # parameter is_persistent seems not working + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.1.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if station_id in entry.data[CONF_STATION_ID]: + return self.async_abort( + reason="already_configured", + ) + + if self._client is None: + self._client = ZamgData() + self._client.session = async_get_clientsession(self.hass) + + if station_id not in await self._client.zamg_stations(): + LOGGER.warning( + "Configured station_id %s could not be found at zamg, adding the nearest weather station instead", + station_id, + ) + latitude = config.get(CONF_LATITUDE) or self.hass.config.latitude + longitude = config.get(CONF_LONGITUDE) or self.hass.config.longitude + station_id = await self._client.closest_station(latitude, longitude) + + if not station_name: + await self._client.zamg_stations() + self._client.set_default_station(station_id) + station_name = self._client.get_station_name + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if station_id in entry.data[CONF_STATION_ID]: + return self.async_abort( + reason="already_configured", + ) + + LOGGER.debug( + "importing zamg station from configuration.yaml: station_id = %s, name = %s", + station_id, + station_name, + ) + + return await self.async_step_user( + user_input={ + CONF_STATION_ID: int(station_id), + CONF_NAME: station_name, + } + ) diff --git a/homeassistant/components/zamg/const.py b/homeassistant/components/zamg/const.py new file mode 100644 index 0000000000000000000000000000000000000000..08dfd1779eabec38e77b5230949bdb5432741245 --- /dev/null +++ b/homeassistant/components/zamg/const.py @@ -0,0 +1,26 @@ +"""Constants for zamg the Austrian "Zentralanstalt für Meteorologie und Geodynamik" integration.""" + +from datetime import timedelta +import logging + +from homeassistant.const import Platform +from homeassistant.util import dt as dt_util + +DOMAIN = "zamg" + +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] + +LOGGER = logging.getLogger(__package__) + +ATTR_STATION = "station" +ATTR_UPDATED = "updated" +ATTRIBUTION = "Data provided by ZAMG" + +CONF_STATION_ID = "station_id" + +DEFAULT_NAME = "zamg" + +MANUFACTURER_URL = "https://www.zamg.ac.at" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) +VIENNA_TIME_ZONE = dt_util.get_time_zone("Europe/Vienna") diff --git a/homeassistant/components/zamg/coordinator.py b/homeassistant/components/zamg/coordinator.py new file mode 100644 index 0000000000000000000000000000000000000000..69113e8e23f1bfb8f292fa3b7945846a0c1066bd --- /dev/null +++ b/homeassistant/components/zamg/coordinator.py @@ -0,0 +1,46 @@ +"""Data Update coordinator for ZAMG weather data.""" +from __future__ import annotations + +from zamg import ZamgData as ZamgDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_STATION_ID, DOMAIN, LOGGER, MIN_TIME_BETWEEN_UPDATES + + +class ZamgDataUpdateCoordinator(DataUpdateCoordinator[ZamgDevice]): + """Class to manage fetching ZAMG weather data.""" + + config_entry: ConfigEntry + data: dict = {} + + def __init__( + self, + hass: HomeAssistant, + *, + entry: ConfigEntry, + ) -> None: + """Initialize global ZAMG data updater.""" + self.zamg = ZamgDevice(session=async_get_clientsession(hass)) + self.zamg.set_default_station(entry.data[CONF_STATION_ID]) + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + + async def _async_update_data(self) -> ZamgDevice: + """Fetch data from ZAMG api.""" + try: + await self.zamg.zamg_stations() + device = await self.zamg.update() + except ValueError as error: + raise UpdateFailed(f"Invalid response from API: {error}") from error + self.data = device + self.data["last_update"] = self.zamg.last_update + self.data["Name"] = self.zamg.get_station_name + return device diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index fc4345141896da983349b2dea26458cb703ed7e8..a6383ce8584a1912abba4db0606a1d816ac3b875 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -2,6 +2,8 @@ "domain": "zamg", "name": "Zentralanstalt f\u00fcr Meteorologie und Geodynamik (ZAMG)", "documentation": "https://www.home-assistant.io/integrations/zamg", - "codeowners": [], + "requirements": ["zamg==0.1.1"], + "codeowners": ["@killer0071234"], + "config_flow": true, "iot_class": "cloud_polling" } diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 73902b1982f7f79250eb75e14ad2edd48df93c9e..e40f42abf0b3d0e35c6f8b53dce637c61c137dce 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -1,55 +1,51 @@ -"""Sensor for the Austrian "Zentralanstalt für Meteorologie und Geodynamik".""" +"""Sensor for zamg the Austrian "Zentralanstalt für Meteorologie und Geodynamik" integration.""" from __future__ import annotations -import csv +from collections.abc import Mapping from dataclasses import dataclass -from datetime import datetime, timedelta -import gzip -import json -import logging -import os from typing import Union -import requests import voluptuous as vol from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - AREA_SQUARE_METERS, + ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, DEGREE, - LENGTH_METERS, + LENGTH_CENTIMETERS, + LENGTH_MILLIMETERS, PERCENTAGE, PRESSURE_HPA, - SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, - __version__, + TIME_SECONDS, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt as dt_util - -_LOGGER = logging.getLogger(__name__) - -ATTR_STATION = "station" -ATTR_UPDATED = "updated" -ATTRIBUTION = "Data provided by ZAMG" - -CONF_STATION_ID = "station_id" - -DEFAULT_NAME = "zamg" - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) -VIENNA_TIME_ZONE = dt_util.get_time_zone("Europe/Vienna") +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + ATTR_STATION, + ATTR_UPDATED, + ATTRIBUTION, + CONF_STATION_ID, + DEFAULT_NAME, + DOMAIN, + MANUFACTURER_URL, +) _DType = Union[type[int], type[float], type[str]] @@ -58,7 +54,7 @@ _DType = Union[type[int], type[float], type[str]] class ZamgRequiredKeysMixin: """Mixin for required keys.""" - col_heading: str + para_name: str dtype: _DType @@ -72,56 +68,67 @@ SENSOR_TYPES: tuple[ZamgSensorEntityDescription, ...] = ( key="pressure", name="Pressure", native_unit_of_measurement=PRESSURE_HPA, - col_heading="LDstat hPa", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + para_name="P", dtype=float, ), ZamgSensorEntityDescription( key="pressure_sealevel", name="Pressure at Sea Level", native_unit_of_measurement=PRESSURE_HPA, - col_heading="LDred hPa", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + para_name="PRED", dtype=float, ), ZamgSensorEntityDescription( key="humidity", name="Humidity", native_unit_of_measurement=PERCENTAGE, - col_heading="RF %", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + para_name="RFAM", dtype=int, ), ZamgSensorEntityDescription( key="wind_speed", name="Wind Speed", - native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, - col_heading=f"WG {SPEED_KILOMETERS_PER_HOUR}", + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + para_name="FFAM", dtype=float, ), ZamgSensorEntityDescription( key="wind_bearing", name="Wind Bearing", native_unit_of_measurement=DEGREE, - col_heading=f"WR {DEGREE}", + state_class=SensorStateClass.MEASUREMENT, + para_name="DD", dtype=int, ), ZamgSensorEntityDescription( key="wind_max_speed", name="Top Wind Speed", - native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, - col_heading=f"WSG {SPEED_KILOMETERS_PER_HOUR}", + native_unit_of_measurement=SPEED_METERS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + para_name="FFX", dtype=float, ), ZamgSensorEntityDescription( key="wind_max_bearing", name="Top Wind Bearing", native_unit_of_measurement=DEGREE, - col_heading=f"WSR {DEGREE}", + state_class=SensorStateClass.MEASUREMENT, + para_name="DDX", dtype=int, ), ZamgSensorEntityDescription( - key="sun_last_hour", - name="Sun Last Hour", - native_unit_of_measurement=PERCENTAGE, - col_heading=f"SO {PERCENTAGE}", + key="sun_last_10min", + name="Sun Last 10 Minutes", + native_unit_of_measurement=TIME_SECONDS, + state_class=SensorStateClass.MEASUREMENT, + para_name="SO", dtype=int, ), ZamgSensorEntityDescription( @@ -129,14 +136,33 @@ SENSOR_TYPES: tuple[ZamgSensorEntityDescription, ...] = ( name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - col_heading=f"T {TEMP_CELSIUS}", + state_class=SensorStateClass.MEASUREMENT, + para_name="TL", + dtype=float, + ), + ZamgSensorEntityDescription( + key="temperature_average", + name="Temperature Average", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + para_name="TLAM", dtype=float, ), ZamgSensorEntityDescription( key="precipitation", name="Precipitation", - native_unit_of_measurement=f"l/{AREA_SQUARE_METERS}", - col_heading=f"N l/{AREA_SQUARE_METERS}", + native_unit_of_measurement=LENGTH_MILLIMETERS, + state_class=SensorStateClass.MEASUREMENT, + para_name="RR", + dtype=float, + ), + ZamgSensorEntityDescription( + key="snow", + name="Snow", + native_unit_of_measurement=LENGTH_CENTIMETERS, + state_class=SensorStateClass.MEASUREMENT, + para_name="SCHNEE", dtype=float, ), ZamgSensorEntityDescription( @@ -144,42 +170,25 @@ SENSOR_TYPES: tuple[ZamgSensorEntityDescription, ...] = ( name="Dew Point", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - col_heading=f"TP {TEMP_CELSIUS}", + state_class=SensorStateClass.MEASUREMENT, + para_name="TP", dtype=float, ), - # The following probably not useful for general consumption, - # but we need them to fill in internal attributes - ZamgSensorEntityDescription( - key="station_name", - name="Station Name", - col_heading="Name", - dtype=str, - ), ZamgSensorEntityDescription( - key="station_elevation", - name="Station Elevation", - native_unit_of_measurement=LENGTH_METERS, - col_heading=f"Höhe {LENGTH_METERS}", - dtype=int, - ), - ZamgSensorEntityDescription( - key="update_date", - name="Update Date", - col_heading="Datum", - dtype=str, - ), - ZamgSensorEntityDescription( - key="update_time", - name="Update Time", - col_heading="Zeit", - dtype=str, + key="dewpoint_average", + name="Dew Point Average", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + para_name="TPAM", + dtype=float, ), ) SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] API_FIELDS: dict[str, tuple[str, _DType]] = { - desc.col_heading: (desc.key, desc.dtype) for desc in SENSOR_TYPES + desc.para_name: (desc.key, desc.dtype) for desc in SENSOR_TYPES } PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( @@ -199,187 +208,70 @@ PLATFORM_SCHEMA = cv.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 ZAMG sensor platform.""" - name = config[CONF_NAME] - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - station_id = config.get(CONF_STATION_ID) or closest_station( - latitude, longitude, hass.config.config_dir + # trigger import flow + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, ) - if station_id not in _get_ogd_stations(): - _LOGGER.error( - "Configured ZAMG %s (%s) is not a known station", - CONF_STATION_ID, - station_id, - ) - return - probe = ZamgData(station_id=station_id) - try: - probe.update() - except (ValueError, TypeError) as err: - _LOGGER.error("Received error from ZAMG: %s", err) - return - monitored_conditions = config[CONF_MONITORED_CONDITIONS] - add_entities( - [ - ZamgSensor(probe, name, description) - for description in SENSOR_TYPES - if description.key in monitored_conditions - ], - True, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the ZAMG sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + ZamgSensor(coordinator, entry.title, entry.data[CONF_STATION_ID], description) + for description in SENSOR_TYPES ) -class ZamgSensor(SensorEntity): +class ZamgSensor(CoordinatorEntity, SensorEntity): """Implementation of a ZAMG sensor.""" _attr_attribution = ATTRIBUTION entity_description: ZamgSensorEntityDescription - def __init__(self, probe, name, description: ZamgSensorEntityDescription): + def __init__( + self, coordinator, name, station_id, description: ZamgSensorEntityDescription + ): """Initialize the sensor.""" + super().__init__(coordinator) self.entity_description = description - self.probe = probe - self._attr_name = f"{name} {description.key}" + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{station_id}_{description.key}" + self.station_id = f"{station_id}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, station_id)}, + manufacturer=ATTRIBUTION, + configuration_url=MANUFACTURER_URL, + name=coordinator.name, + ) @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.probe.get_data(self.entity_description.key) + return self.coordinator.data[self.station_id].get( + self.entity_description.para_name + )["data"] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, str]: """Return the state attributes.""" + update_time = self.coordinator.data.get("last_update", "") return { - ATTR_STATION: self.probe.get_data("station_name"), - ATTR_UPDATED: self.probe.last_update.isoformat(), + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_STATION: self.coordinator.data.get("Name"), + CONF_STATION_ID: self.station_id, + ATTR_UPDATED: update_time.isoformat(), } - - def update(self) -> None: - """Delegate update to probe.""" - self.probe.update() - - -class ZamgData: - """The class for handling the data retrieval.""" - - API_URL = "http://www.zamg.ac.at/ogd/" - API_HEADERS = {"User-Agent": f"home-assistant.zamg/ {__version__}"} - - def __init__(self, station_id): - """Initialize the probe.""" - self._station_id = station_id - self.data = {} - - @property - def last_update(self): - """Return the timestamp of the most recent data.""" - date, time = self.data.get("update_date"), self.data.get("update_time") - if date is not None and time is not None: - return datetime.strptime(date + time, "%d-%m-%Y%H:%M").replace( - tzinfo=VIENNA_TIME_ZONE - ) - - @classmethod - def current_observations(cls): - """Fetch the latest CSV data.""" - try: - response = requests.get(cls.API_URL, headers=cls.API_HEADERS, timeout=15) - response.raise_for_status() - response.encoding = "UTF8" - return csv.DictReader( - response.text.splitlines(), delimiter=";", quotechar='"' - ) - except requests.exceptions.HTTPError: - _LOGGER.error("While fetching data") - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from ZAMG.""" - if self.last_update and ( - self.last_update + timedelta(hours=1) - > datetime.utcnow().replace(tzinfo=dt_util.UTC) - ): - return # Not time to update yet; data is only hourly - - for row in self.current_observations(): - if row.get("Station") == self._station_id: - self.data = { - API_FIELDS[col_heading][0]: API_FIELDS[col_heading][1]( - v.replace(",", ".") - ) - for col_heading, v in row.items() - if col_heading in API_FIELDS and v - } - break - else: - raise ValueError(f"No weather data for station {self._station_id}") - - def get_data(self, variable): - """Get the data.""" - return self.data.get(variable) - - -def _get_ogd_stations(): - """Return all stations in the OGD dataset.""" - return {r["Station"] for r in ZamgData.current_observations()} - - -def _get_zamg_stations(): - """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config.""" - capital_stations = _get_ogd_stations() - req = requests.get( - "https://www.zamg.ac.at/cms/en/documents/climate/" - "doc_metnetwork/zamg-observation-points", - timeout=15, - ) - stations = {} - for row in csv.DictReader(req.text.splitlines(), delimiter=";", quotechar='"'): - if row.get("synnr") in capital_stations: - try: - stations[row["synnr"]] = tuple( - float(row[coord].replace(",", ".")) - for coord in ("breite_dezi", "länge_dezi") - ) - except KeyError: - _LOGGER.error("ZAMG schema changed again, cannot autodetect station") - return stations - - -def zamg_stations(cache_dir): - """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config. - - Results from internet requests are cached as compressed json, making - subsequent calls very much faster. - """ - cache_file = os.path.join(cache_dir, ".zamg-stations.json.gz") - if not os.path.isfile(cache_file): - stations = _get_zamg_stations() - with gzip.open(cache_file, "wt") as cache: - json.dump(stations, cache, sort_keys=True) - return stations - with gzip.open(cache_file, "rt") as cache: - return {k: tuple(v) for k, v in json.load(cache).items()} - - -def closest_station(lat, lon, cache_dir): - """Return the ZONE_ID.WMO_ID of the closest station to our lat/lon.""" - if lat is None or lon is None or not os.path.isdir(cache_dir): - return - stations = zamg_stations(cache_dir) - - def comparable_dist(zamg_id): - """Calculate the pseudo-distance from lat/lon.""" - station_lat, station_lon = stations[zamg_id] - return (lat - station_lat) ** 2 + (lon - station_lon) ** 2 - - return min(stations, key=comparable_dist) diff --git a/homeassistant/components/zamg/strings.json b/homeassistant/components/zamg/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..74b3c7c9fa288da49e2eb3ca8e0b691403c93e80 --- /dev/null +++ b/homeassistant/components/zamg/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Set up ZAMG to integrate with Home Assistant.", + "data": { + "station_id": "Station ID (Defaults to nearest station)" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The ZAMG YAML configuration is being removed", + "description": "Configuring ZAMG using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the ZAMG YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/zamg/translations/de.json b/homeassistant/components/zamg/translations/de.json new file mode 100644 index 0000000000000000000000000000000000000000..084d65de978ffa559b1be1b55909165e5dbcc8fd --- /dev/null +++ b/homeassistant/components/zamg/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Wetterstation ist bereits konfiguriert", + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "error": { + "unknown": "ID der Wetterstation ist unbekannt", + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "station_id": "ID der Wetterstation (nächstgelegene Station as Defaultwert)" + }, + "description": "Richte zamg f\u00fcr die Integration mit Home Assistant ein." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zamg/translations/en.json b/homeassistant/components/zamg/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..6931f9f96f5d08336f8371343b3c338f0d94aad0 --- /dev/null +++ b/homeassistant/components/zamg/translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "station_id": "Station ID (Defaults to nearest station)" + }, + "description": "Set up ZAMG to integrate with Home Assistant." + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Configuring ZAMG using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the ZAMG YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", + "title": "The ZAMG YAML configuration is being removed" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index eb2992df64f3889df89f63d05695ebf38160468f..b9d8ba67bbfe9bc63a189035dd2c4b2399dae041 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -1,42 +1,29 @@ -"""Sensor for data from Austrian Zentralanstalt für Meteorologie.""" +"""Sensor for zamg the Austrian "Zentralanstalt für Meteorologie und Geodynamik" integration.""" from __future__ import annotations -import logging - import voluptuous as vol -from homeassistant.components.weather import ( - ATTR_WEATHER_HUMIDITY, - ATTR_WEATHER_PRESSURE, - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_WIND_BEARING, - ATTR_WEATHER_WIND_SPEED, - PLATFORM_SCHEMA, - WeatherEntity, -) +from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, + LENGTH_MILLIMETERS, PRESSURE_HPA, - SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -# Reuse data and API logic from the sensor implementation -from .sensor import ( - ATTRIBUTION, - CONF_STATION_ID, - ZamgData, - closest_station, - zamg_stations, -) - -_LOGGER = logging.getLogger(__name__) +from .const import ATTRIBUTION, CONF_STATION_ID, DOMAIN, MANUFACTURER_URL +from .coordinator import ZamgDataUpdateCoordinator PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -52,93 +39,101 @@ 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 ZAMG weather platform.""" - name = config.get(CONF_NAME) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - station_id = config.get(CONF_STATION_ID) or closest_station( - latitude, longitude, hass.config.config_dir + # trigger import flow + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, ) - if station_id not in zamg_stations(hass.config.config_dir): - _LOGGER.error( - "Configured ZAMG %s (%s) is not a known station", - CONF_STATION_ID, - station_id, - ) - return - probe = ZamgData(station_id=station_id) - try: - probe.update() - except (ValueError, TypeError) as err: - _LOGGER.error("Received error from ZAMG: %s", err) - return - add_entities([ZamgWeather(probe, name)], True) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the ZAMG weather platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ZamgWeather(coordinator, entry.title, entry.data[CONF_STATION_ID])] + ) -class ZamgWeather(WeatherEntity): +class ZamgWeather(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, zamg_data, stationname=None): + def __init__( + self, coordinator: ZamgDataUpdateCoordinator, name, station_id + ) -> None: """Initialise the platform with a data instance and station name.""" - self.zamg_data = zamg_data - self.stationname = stationname - - @property - def name(self): - """Return the name of the sensor.""" - return ( - self.stationname - or f"ZAMG {self.zamg_data.data.get('Name') or '(unknown station)'}" + super().__init__(coordinator) + self._attr_unique_id = f"{name}_{station_id}" + self._attr_name = f"ZAMG {name}" + self.station_id = f"{station_id}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, station_id)}, + manufacturer=ATTRIBUTION, + configuration_url=MANUFACTURER_URL, + name=coordinator.name, ) + # set units of ZAMG API + self._attr_native_temperature_unit = TEMP_CELSIUS + self._attr_native_pressure_unit = PRESSURE_HPA + self._attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND + self._attr_native_precipitation_unit = LENGTH_MILLIMETERS @property - def condition(self): + def condition(self) -> str | None: """Return the current condition.""" return None @property - def attribution(self): + def attribution(self) -> str | None: """Return the attribution.""" return ATTRIBUTION @property - def native_temperature(self): + def native_temperature(self) -> float | None: """Return the platform temperature.""" - return self.zamg_data.get_data(ATTR_WEATHER_TEMPERATURE) + try: + return float(self.coordinator.data[self.station_id].get("TL")["data"]) + except (TypeError, ValueError): + return None @property - def native_pressure(self): + def native_pressure(self) -> float | None: """Return the pressure.""" - return self.zamg_data.get_data(ATTR_WEATHER_PRESSURE) + try: + return float(self.coordinator.data[self.station_id].get("P")["data"]) + except (TypeError, ValueError): + return None @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" - return self.zamg_data.get_data(ATTR_WEATHER_HUMIDITY) + try: + return float(self.coordinator.data[self.station_id].get("RFAM")["data"]) + except (TypeError, ValueError): + return None @property - def native_wind_speed(self): + def native_wind_speed(self) -> float | None: """Return the wind speed.""" - return self.zamg_data.get_data(ATTR_WEATHER_WIND_SPEED) + try: + return float(self.coordinator.data[self.station_id].get("FF")["data"]) + except (TypeError, ValueError): + return None @property - def wind_bearing(self): + def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" - return self.zamg_data.get_data(ATTR_WEATHER_WIND_BEARING) - - def update(self) -> None: - """Update current conditions.""" - self.zamg_data.update() + try: + return self.coordinator.data[self.station_id].get("DD")["data"] + except (TypeError, ValueError): + return None diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 5a2fc61f89766985795658c49af8451cd7d5342b..62783e641d392bbd69cd7d4173a8f1a61bf74da3 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -22,11 +22,7 @@ from homeassistant import config_entries from homeassistant.components import network from homeassistant.components.network import MDNS_TARGET_IP, async_get_source_ip from homeassistant.components.network.models import Adapter -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - __version__, -) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import discovery_flow, instance_id @@ -40,6 +36,7 @@ from homeassistant.loader import ( async_get_zeroconf, bind_hass, ) +from homeassistant.setup import async_when_setup_or_start from .models import HaAsyncServiceBrowser, HaAsyncZeroconf, HaZeroconf from .usage import install_multiple_zeroconf_catcher @@ -194,7 +191,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: discovery = ZeroconfDiscovery(hass, zeroconf, zeroconf_types, homekit_models, ipv6) await discovery.async_setup() - async def _async_zeroconf_hass_start(_event: Event) -> None: + async def _async_zeroconf_hass_start(hass: HomeAssistant, comp: str) -> None: """Expose Home Assistant on zeroconf when it starts. Wait till started or otherwise HTTP is not up and running. @@ -206,7 +203,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await discovery.async_stop() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_zeroconf_hass_stop) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_zeroconf_hass_start) + async_when_setup_or_start(hass, "frontend", _async_zeroconf_hass_start) return True @@ -237,12 +234,20 @@ def _get_announced_addresses( return address_list +def _filter_disallowed_characters(name: str) -> str: + """Filter disallowed characters from a string. + + . is a reversed character for zeroconf. + """ + return name.replace(".", " ") + + async def _async_register_hass_zc_service( hass: HomeAssistant, aio_zc: HaAsyncZeroconf, uuid: str ) -> None: # Get instance UUID valid_location_name = _truncate_location_name_to_valid( - hass.config.location_name or "Home" + _filter_disallowed_characters(hass.config.location_name or "Home") ) params = { diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 5fcb514ea51d775cdcebb6cf73ba2d720109197e..382cf42b54fde7e208b11c0a698c22fe80e4353f 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.39.1"], + "requirements": ["zeroconf==0.39.4"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 6cbcdf50983071ecbb44fa9a898538a237e37aa2..c68136c23daf9077b091e6ba5c34437bec3683e3 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import logging -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast import voluptuous as vol import zigpy.backups @@ -31,6 +31,7 @@ from .core.const import ( ATTR_LEVEL, ATTR_MANUFACTURER, ATTR_MEMBERS, + ATTR_PARAMS, ATTR_TYPE, ATTR_VALUE, ATTR_WARNING_DEVICE_DURATION, @@ -69,6 +70,7 @@ from .core.group import GroupMember from .core.helpers import ( async_cluster_exists, async_is_bindable_target, + cluster_command_schema_to_vol_schema, convert_install_code, get_matched_clusters, qr_to_install_code, @@ -110,6 +112,17 @@ IEEE_SERVICE = "ieee_based_service" IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) +# typing typevar +_T = TypeVar("_T") + + +def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: + """Wrap value in list if it is provided and not one.""" + if value is None: + return None + return cast("list[_T]", value) if isinstance(value, list) else [value] + + SERVICE_PERMIT_PARAMS = { vol.Optional(ATTR_IEEE): IEEE_SCHEMA, vol.Optional(ATTR_DURATION, default=60): vol.All( @@ -181,17 +194,22 @@ SERVICE_SCHEMAS = { ): cv.positive_int, } ), - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_COMMAND): cv.positive_int, - vol.Required(ATTR_COMMAND_TYPE): cv.string, - vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Required(ATTR_COMMAND_TYPE): cv.string, + vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, + vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), + cv.deprecated(ATTR_ARGS), + cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), ), SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( { @@ -711,6 +729,8 @@ async def websocket_device_cluster_commands( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of cluster commands.""" + import voluptuous_serialize # pylint: disable=import-outside-toplevel + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] ieee: EUI64 = msg[ATTR_IEEE] endpoint_id: int = msg[ATTR_ENDPOINT_ID] @@ -731,6 +751,10 @@ async def websocket_device_cluster_commands( TYPE: CLIENT, ID: cmd_id, ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), } ) for cmd_id, cmd in commands[CLUSTER_COMMANDS_SERVER].items(): @@ -739,6 +763,10 @@ async def websocket_device_cluster_commands( TYPE: CLUSTER_COMMAND_SERVER, ID: cmd_id, ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), } ) _LOGGER.debug( @@ -1285,41 +1313,45 @@ def async_load_api(hass: HomeAssistant) -> None: cluster_type: str = service.data[ATTR_CLUSTER_TYPE] command: int = service.data[ATTR_COMMAND] command_type: str = service.data[ATTR_COMMAND_TYPE] - args: list = service.data[ATTR_ARGS] + args: list | None = service.data.get(ATTR_ARGS) + params: dict | None = service.data.get(ATTR_PARAMS) manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) zha_device = zha_gateway.get_device(ieee) - response = None if zha_device is not None: if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: manufacturer = zha_device.manufacturer_code - response = await zha_device.issue_cluster_command( + + await zha_device.issue_cluster_command( endpoint_id, cluster_id, command, command_type, - *args, + args, + params, cluster_type=cluster_type, manufacturer=manufacturer, ) - _LOGGER.debug( - "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_COMMAND, - command, - ATTR_COMMAND_TYPE, - command_type, - ATTR_ARGS, - args, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) + _LOGGER.debug( + "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_COMMAND, + command, + ATTR_COMMAND_TYPE, + command_type, + ATTR_ARGS, + args, + ATTR_PARAMS, + params, + ATTR_MANUFACTURER, + manufacturer, + ) + else: + raise ValueError(f"Device with IEEE {str(ieee)} not found") async_register_admin_service( hass, diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index fcc040cbde2e161ee82431dadfb2aa9a6fa0cd25..41f3846e97f783cf0ecfe680bd3df131538a4a7a 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -109,6 +109,7 @@ class ZHAIdentifyButton(ZHAButton): _attr_device_class: ButtonDeviceClass = ButtonDeviceClass.UPDATE _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_name = "Identify" _command_name = "identify" def get_args(self) -> list[Any]: @@ -118,7 +119,7 @@ class ZHAIdentifyButton(ZHAButton): class ZHAAttributeButton(ZhaEntity, ButtonEntity): - """Defines a ZHA button, which stes value to an attribute.""" + """Defines a ZHA button, which writes a value to an attribute.""" _attribute_name: str _attribute_value: Any = None @@ -159,6 +160,7 @@ class FrostLockResetButton(ZHAAttributeButton, id_suffix="reset_frost_lock"): """Defines a ZHA frost lock reset button.""" _attribute_name = "frost_lock_reset" + _attr_name = "Frost lock reset" _attribute_value = 0 _attr_device_class = ButtonDeviceClass.RESTART _attr_entity_category = EntityCategory.CONFIG @@ -171,6 +173,7 @@ class NoPresenceStatusResetButton( """Defines a ZHA no presence status reset button.""" _attribute_name = "reset_no_presence_status" + _attr_name = "Presence status reset" _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 a4e1be78c08b5b9a0db934bcc40da394ede6f145..155a254217e2c7835b806a3c436981e220f70c2b 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -765,6 +765,7 @@ class StelproFanHeater(Thermostat): "_TZE200_e9ba97vf", # TV01-ZG "_TZE200_hue3yfsn", # TV02-ZG "_TZE200_husqqvux", # TSL-TRV-TV01ZG + "_TZE200_kds0pmmv", # MOES TRV TV02 "_TZE200_kly8gjlz", # TV05-ZG "_TZE200_lnbfnyxd", "_TZE200_mudxchsu", diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index d310157327b62995dc4b4e2e2a82ecc7349960c6..c028a6021da9c586c62353c55b11fda5b24d9488 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -28,6 +28,7 @@ from ..const import ( SIGNAL_UPDATE_DEVICE, ) from .base import AttrReportConfig, ClientChannel, ZigbeeChannel, parse_and_log_command +from .helpers import is_hue_motion_sensor if TYPE_CHECKING: from . import ChannelPool @@ -152,6 +153,15 @@ class BasicChannel(ZigbeeChannel): 6: "Emergency mains and transfer switch", } + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: + """Initialize Basic channel.""" + super().__init__(cluster, ch_pool) + if is_hue_motion_sensor(self) and self.cluster.endpoint.endpoint_id == 2: + self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS.copy() + ) + self.ZCL_INIT_ATTRS["trigger_indicator"] = True + @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryInput.cluster_id) class BinaryInput(ZigbeeChannel): @@ -331,6 +341,19 @@ class OnOffChannel(ZigbeeChannel): super().__init__(cluster, ch_pool) self._off_listener = None + if self.cluster.endpoint.model in ( + "TS011F", + "TS0121", + "TS0001", + "TS0002", + "TS0003", + "TS0004", + ): + self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS.copy() + ) + self.ZCL_INIT_ATTRS["power_on_state"] = True + @property def on_off(self) -> bool | None: """Return cached value of on/off attribute.""" diff --git a/homeassistant/components/zha/core/channels/helpers.py b/homeassistant/components/zha/core/channels/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..2297af312ebe6fcfd52c21b208e2712acf8dd066 --- /dev/null +++ b/homeassistant/components/zha/core/channels/helpers.py @@ -0,0 +1,15 @@ +"""Helpers for use with ZHA Zigbee channels.""" +from .base import ZigbeeChannel + + +def is_hue_motion_sensor(channel: ZigbeeChannel) -> bool: + """Return true if the manufacturer and model match known Hue motion sensor models.""" + return channel.cluster.endpoint.manufacturer in ( + "Philips", + "Signify Netherlands B.V.", + ) and channel.cluster.endpoint.model in ( + "SML001", + "SML002", + "SML003", + "SML004", + ) diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index 1754b9aff68e10c6c7932543a832db5d63b8fa45..e70eea11a8742a60a5ffa8e41823c69b708d2552 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -44,6 +44,7 @@ class ColorChannel(ZigbeeChannel): "color_temp_physical_max": True, "color_capabilities": True, "color_loop_active": False, + "start_up_color_temperature": True, } @cached_property diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 724a794007d558687eeddda33e33c07bc26e6b02..814e7700d01e04628b3118c87422f9775ae424d1 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -59,10 +59,45 @@ class PhillipsRemote(ZigbeeChannel): REPORT_CONFIG = () +@registries.CHANNEL_ONLY_CLUSTERS.register(registries.TUYA_MANUFACTURER_CLUSTER) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.TUYA_MANUFACTURER_CLUSTER) +class TuyaChannel(ZigbeeChannel): + """Channel for the Tuya manufacturer Zigbee cluster.""" + + REPORT_CONFIG = () + + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: + """Initialize TuyaChannel.""" + super().__init__(cluster, ch_pool) + + if self.cluster.endpoint.manufacturer in ( + "_TZE200_7tdtqgwv", + "_TZE200_amp6tsvy", + "_TZE200_oisqyl4o", + "_TZE200_vhy3iakz", + "_TZ3000_uim07oem", + "_TZE200_wfxuhoea", + "_TZE200_tviaymwx", + "_TZE200_g1ib5ldv", + "_TZE200_wunufsil", + "_TZE200_7deq70b8", + "_TZE200_tz32mtza", + "_TZE200_2hf7x9n3", + "_TZE200_aqnazj70", + "_TZE200_1ozguk6x", + "_TZE200_k6jhsr0q", + "_TZE200_9mahtqtg", + ): + self.ZCL_INIT_ATTRS = { # pylint: disable=invalid-name + "backlight_mode": True, + "power_on_state": True, + } + + @registries.CHANNEL_ONLY_CLUSTERS.register(0xFCC0) @registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFCC0) class OppleRemote(ZigbeeChannel): - """Opple button channel.""" + """Opple channel.""" REPORT_CONFIG = () @@ -82,6 +117,10 @@ class OppleRemote(ZigbeeChannel): "motion_sensitivity": True, "approach_distance": True, } + elif self.cluster.endpoint.model in ("lumi.plug.mmeu01", "lumi.plug.maeu01"): + self.ZCL_INIT_ATTRS = { + "power_outage_memory": True, + } async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Initialize channel specific.""" @@ -158,7 +197,7 @@ class InovelliConfigEntityChannel(ZigbeeChannel): Clear = 0xFF REPORT_CONFIG = () - ZCL_INIT_ATTRS = { # pylint: disable=invalid-name + ZCL_INIT_ATTRS = { "dimming_speed_up_remote": False, "dimming_speed_up_local": False, "ramp_rate_off_to_on_local": False, diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py index fa6f9c07dee88b2cd5f8f3f52b08052e3d52cda3..be61a75962ee23659c06b425da42d37e5c4f888f 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/channels/measurement.py @@ -1,4 +1,9 @@ """Measurement channels module for Zigbee Home Automation.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import zigpy.zcl from zigpy.zcl.clusters import measurement from .. import registries @@ -9,6 +14,10 @@ from ..const import ( REPORT_CONFIG_MIN_INT, ) from .base import AttrReportConfig, ZigbeeChannel +from .helpers import is_hue_motion_sensor + +if TYPE_CHECKING: + from . import ChannelPool @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.FlowMeasurement.cluster_id) @@ -50,6 +59,15 @@ class OccupancySensing(ZigbeeChannel): AttrReportConfig(attr="occupancy", config=REPORT_CONFIG_IMMEDIATE), ) + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: + """Initialize Occupancy channel.""" + super().__init__(cluster, ch_pool) + if is_hue_motion_sensor(self): + self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS.copy() + ) + self.ZCL_INIT_ATTRS["sensitivity"] = True + @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PressureMeasurement.cluster_id) class PressureMeasurement(ZigbeeChannel): diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index a0a4521e19d2132ffb237e516ce0f8d35d7c0882..5eb436cbe5310618de9197807d828543e5f3f9f8 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -17,11 +17,14 @@ import zigpy.exceptions from zigpy.profiles import PROFILES import zigpy.quirks from zigpy.types.named import EUI64, NWK +from zigpy.zcl.clusters import Cluster from zigpy.zcl.clusters.general import Groups, Identify +from zigpy.zcl.foundation import Status as ZclStatus, ZCLCommandDef import zigpy.zdo.types as zdo_types from homeassistant.const import ATTR_COMMAND, ATTR_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -35,6 +38,7 @@ from .const import ( ATTR_ATTRIBUTE, ATTR_AVAILABLE, ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, ATTR_COMMAND_TYPE, ATTR_DEVICE_TYPE, ATTR_ENDPOINT_ID, @@ -49,6 +53,7 @@ from .const import ( ATTR_NEIGHBORS, ATTR_NODE_DESCRIPTOR, ATTR_NWK, + ATTR_PARAMS, ATTR_POWER_SOURCE, ATTR_QUIRK_APPLIED, ATTR_QUIRK_CLASS, @@ -74,7 +79,7 @@ from .const import ( UNKNOWN_MODEL, ZHA_OPTIONS, ) -from .helpers import LogMixin, async_get_zha_config_value +from .helpers import LogMixin, async_get_zha_config_value, convert_to_zcl_values if TYPE_CHECKING: from ..api import ClusterBinding @@ -558,7 +563,7 @@ class ZHADevice(LogMixin): return device_info @callback - def async_get_clusters(self): + def async_get_clusters(self) -> dict[int, dict[str, dict[int, Cluster]]]: """Get all clusters for this device.""" return { ep_id: { @@ -592,9 +597,11 @@ class ZHADevice(LogMixin): } @callback - def async_get_cluster(self, endpoint_id, cluster_id, cluster_type=CLUSTER_TYPE_IN): + def async_get_cluster( + self, endpoint_id: int, cluster_id: int, cluster_type: str = CLUSTER_TYPE_IN + ) -> Cluster: """Get zigbee cluster from this entity.""" - clusters = self.async_get_clusters() + clusters: dict[int, dict[str, dict[int, Cluster]]] = self.async_get_clusters() return clusters[endpoint_id][cluster_type][cluster_id] @callback @@ -660,36 +667,62 @@ class ZHADevice(LogMixin): async def issue_cluster_command( self, - endpoint_id, - cluster_id, - command, - command_type, - *args, - cluster_type=CLUSTER_TYPE_IN, - manufacturer=None, - ): - """Issue a command against specified zigbee cluster on this entity.""" - cluster = self.async_get_cluster(endpoint_id, cluster_id, cluster_type) - if cluster is None: - return None - if command_type == CLUSTER_COMMAND_SERVER: - response = await cluster.command( - command, *args, manufacturer=manufacturer, expect_reply=True + endpoint_id: int, + cluster_id: int, + command: int, + command_type: str, + args: list | None, + params: dict[str, Any] | None, + cluster_type: str = CLUSTER_TYPE_IN, + manufacturer: int | None = None, + ) -> None: + """Issue a command against specified zigbee cluster on this device.""" + try: + cluster: Cluster = self.async_get_cluster( + endpoint_id, cluster_id, cluster_type + ) + except KeyError as exc: + raise ValueError( + f"Cluster {cluster_id} not found on endpoint {endpoint_id} while issuing command {command} with args {args}" + ) from exc + commands: dict[int, ZCLCommandDef] = ( + cluster.server_commands + if command_type == CLUSTER_COMMAND_SERVER + else cluster.client_commands + ) + if args is not None: + self.warning( + "args [%s] are deprecated and should be passed with the params key. The parameter names are: %s", + args, + [field.name for field in commands[command].schema.fields], ) + response = await getattr(cluster, commands[command].name)(*args) else: - response = await cluster.client_command(command, *args) - + assert params is not None + response = await ( + getattr(cluster, commands[command].name)( + **convert_to_zcl_values(params, commands[command].schema) + ) + ) self.debug( - "Issued cluster command: %s %s %s %s %s %s %s", - f"{ATTR_CLUSTER_ID}: {cluster_id}", - f"{ATTR_COMMAND}: {command}", - f"{ATTR_COMMAND_TYPE}: {command_type}", - f"{ATTR_ARGS}: {args}", - f"{ATTR_CLUSTER_ID}: {cluster_type}", - f"{ATTR_MANUFACTURER}: {manufacturer}", - f"{ATTR_ENDPOINT_ID}: {endpoint_id}", + "Issued cluster command: %s %s %s %s %s %s %s %s", + f"{ATTR_CLUSTER_ID}: [{cluster_id}]", + f"{ATTR_CLUSTER_TYPE}: [{cluster_type}]", + f"{ATTR_ENDPOINT_ID}: [{endpoint_id}]", + f"{ATTR_COMMAND}: [{command}]", + f"{ATTR_COMMAND_TYPE}: [{command_type}]", + f"{ATTR_ARGS}: [{args}]", + f"{ATTR_PARAMS}: [{params}]", + f"{ATTR_MANUFACTURER}: [{manufacturer}]", ) - return response + if response is None: + return # client commands don't return a response + if isinstance(response, Exception): + raise HomeAssistantError("Failed to issue cluster command") from response + if response[1] is not ZclStatus.SUCCESS: + raise HomeAssistantError( + f"Failed to issue cluster command with status: {response[1]}" + ) async def async_add_to_group(self, group_id: int) -> None: """Add this device to the provided zigbee group.""" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 5261396c79461af3c71a840cabc51c1a6c3de100..17adc5fc8487a13e3c5f59466bac2929f7fe8051 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -754,7 +754,7 @@ class LogRelayHandler(logging.Handler): "|".join([re.escape(x) for x in (hass_path, config_dir)]) ) ) - entry = LogEntry(record, stack, _figure_out_source(record, stack, paths_re)) + entry = LogEntry(record, _figure_out_source(record, stack, paths_re)) async_dispatcher_send( self.hass, ZHA_GW_MSG, diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 7fd789ac3f51bd30af5cfd495ca9feeec247fa4c..1ea9a2a4c9be2be20dc0c11be8ad3231b0e67302 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -10,6 +10,7 @@ import asyncio import binascii from collections.abc import Callable, Iterator from dataclasses import dataclass +import enum import functools import itertools import logging @@ -22,12 +23,13 @@ import zigpy.exceptions import zigpy.types import zigpy.util import zigpy.zcl +from zigpy.zcl.foundation import CommandSchema import zigpy.zdo.types as zdo_types from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import IntegrationError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( CLUSTER_TYPE_IN, @@ -120,6 +122,74 @@ async def get_matched_clusters( return clusters_to_bind +def cluster_command_schema_to_vol_schema(schema: CommandSchema) -> vol.Schema: + """Convert a cluster command schema to a voluptuous schema.""" + return vol.Schema( + { + vol.Optional(field.name) + if field.optional + else vol.Required(field.name): schema_type_to_vol(field.type) + for field in schema.fields + } + ) + + +def schema_type_to_vol(field_type: Any) -> Any: + """Convert a schema type to a voluptuous type.""" + if issubclass(field_type, enum.Flag) and len(field_type.__members__.keys()): + return cv.multi_select( + [key.replace("_", " ") for key in field_type.__members__.keys()] + ) + if issubclass(field_type, enum.Enum) and len(field_type.__members__.keys()): + return vol.In([key.replace("_", " ") for key in field_type.__members__.keys()]) + if ( + issubclass(field_type, zigpy.types.FixedIntType) + or issubclass(field_type, enum.Flag) + or issubclass(field_type, enum.Enum) + ): + return vol.All( + vol.Coerce(int), vol.Range(field_type.min_value, field_type.max_value) + ) + return str + + +def convert_to_zcl_values( + fields: dict[str, Any], schema: CommandSchema +) -> dict[str, Any]: + """Convert user input to ZCL values.""" + converted_fields: dict[str, Any] = {} + for field in schema.fields: + if field.name not in fields: + continue + value = fields[field.name] + if issubclass(field.type, enum.Flag) and isinstance(value, list): + new_value = 0 + + for flag in value: + if isinstance(flag, str): + new_value |= field.type[flag.replace(" ", "_")] + else: + new_value |= flag + + value = field.type(new_value) + elif issubclass(field.type, enum.Enum): + value = ( + field.type[value.replace(" ", "_")] + if isinstance(value, str) + else field.type(value) + ) + else: + value = field.type(value) + _LOGGER.debug( + "Converted ZCL schema field(%s) value from: %s to: %s", + field.name, + fields[field.name], + value, + ) + converted_fields[field.name] = value + return converted_fields + + @callback def async_is_bindable_target(source_zha_device, target_zha_device): """Determine if target is bindable to source.""" diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 2480cf1cd43a201100b262b918c71bbe2ca251db..42f6bb55f5199a16877385ed5f6d4b11a2458a01 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -33,6 +33,7 @@ PHILLIPS_REMOTE_CLUSTER = 0xFC00 SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02 SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000 SMARTTHINGS_HUMIDITY_CLUSTER = 0xFC45 +TUYA_MANUFACTURER_CLUSTER = 0xEF00 VOC_LEVEL_CLUSTER = 0x042E REMOTE_DEVICE_TYPES = { diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 1cb988b1c1513f57c8e262e9578b3d8eddd386d2..3e2a3591c804cfcfdbd754726dd0749cfafb36a5 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -93,7 +93,7 @@ DEVICE_ACTION_SCHEMAS = { ), INOVELLI_INDIVIDUAL_LED_EFFECT: vol.Schema( { - vol.Required("led_number"): vol.All(vol.Coerce(int), vol.Range(1, 7)), + vol.Required("led_number"): vol.All(vol.Coerce(int), vol.Range(0, 6)), vol.Required("effect_type"): vol.In( InovelliConfigEntityChannel.LEDEffectType.__members__.keys() ), diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 5796fc99f3fa1fe588901d3cf73b0e9c32a9e908..e40a54c11bce99a778e1c7ba76e5eabe63b99c1e 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -7,11 +7,11 @@ "bellows==0.34.2", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.83", + "zha-quirks==0.0.84", "zigpy-deconz==0.19.0", - "zigpy==0.51.3", + "zigpy==0.51.5", "zigpy-xbee==0.16.2", - "zigpy-zigate==0.10.2", + "zigpy-zigate==0.10.3", "zigpy-znp==0.9.1" ], "usb": [ diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 3bace412744f364d30a191bc891af8202ab5b64c..1776cabf1256826d7984289317a13c5ba76f368a 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( CHANNEL_ANALOG_OUTPUT, + CHANNEL_COLOR, CHANNEL_INOVELLI, CHANNEL_LEVEL, DATA_ZHA, @@ -455,6 +456,7 @@ class AqaraMotionDetectionInterval( _attr_native_min_value: float = 2 _attr_native_max_value: float = 65535 _zcl_attribute: str = "detection_interval" + _attr_name = "Detection interval" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) @@ -466,6 +468,7 @@ class OnOffTransitionTimeConfigurationEntity( _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFF _zcl_attribute: str = "on_off_transition_time" + _attr_name = "On/Off transition time" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) @@ -475,6 +478,7 @@ class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_lev _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFF _zcl_attribute: str = "on_level" + _attr_name = "On level" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) @@ -486,6 +490,7 @@ class OnTransitionTimeConfigurationEntity( _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFE _zcl_attribute: str = "on_transition_time" + _attr_name = "On transition time" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) @@ -497,6 +502,7 @@ class OffTransitionTimeConfigurationEntity( _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFE _zcl_attribute: str = "off_transition_time" + _attr_name = "Off transition time" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) @@ -508,6 +514,7 @@ class DefaultMoveRateConfigurationEntity( _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFE _zcl_attribute: str = "default_move_rate" + _attr_name = "Default move rate" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_LEVEL) @@ -519,6 +526,32 @@ class StartUpCurrentLevelConfigurationEntity( _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFF _zcl_attribute: str = "start_up_current_level" + _attr_name = "Start-up current level" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_COLOR) +class StartUpColorTemperatureConfigurationEntity( + ZHANumberConfigurationEntity, id_suffix="start_up_color_temperature" +): + """Representation of a ZHA startup color temperature configuration entity.""" + + _attr_native_min_value: float = 153 + _attr_native_max_value: float = 500 + _zcl_attribute: str = "start_up_color_temperature" + _attr_name = "Start-up color temperature" + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + channels: list[ZigbeeChannel], + **kwargs: Any, + ) -> None: + """Init this ZHA startup color temperature entity.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + if self._channel: + self._attr_native_min_value: float = self._channel.min_mireds + self._attr_native_max_value: float = self._channel.max_mireds @CONFIG_DIAGNOSTIC_MATCH( @@ -536,6 +569,7 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati _attr_native_max_value: float = 0x257 _attr_native_unit_of_measurement: str | None = UNITS[72] _zcl_attribute: str = "timer_duration" + _attr_name = "Timer duration" @CONFIG_DIAGNOSTIC_MATCH(channel_names="ikea_airpurifier") @@ -548,6 +582,7 @@ class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time") _attr_native_max_value: float = 0xFFFFFFFF _attr_native_unit_of_measurement: str | None = UNITS[72] _zcl_attribute: str = "filter_life_time" + _attr_name = "Filter life time" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 8b2623b4de1e807c4000cf154a33f8b1731df9ac..5ac0ec6d16408f8bdfc740381404c38c4b805f95 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -22,6 +22,7 @@ from .core import discovery from .core.const import ( CHANNEL_IAS_WD, CHANNEL_INOVELLI, + CHANNEL_OCCUPANCY, CHANNEL_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, @@ -123,6 +124,7 @@ class ZHADefaultToneSelectEntity( """Representation of a ZHA default siren tone select entity.""" _enum = IasWd.Warning.WarningMode + _attr_name = "Default siren tone" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) @@ -132,6 +134,7 @@ class ZHADefaultSirenLevelSelectEntity( """Representation of a ZHA default siren level select entity.""" _enum = IasWd.Warning.SirenLevel + _attr_name = "Default siren level" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) @@ -141,6 +144,7 @@ class ZHADefaultStrobeLevelSelectEntity( """Representation of a ZHA default siren strobe level select entity.""" _enum = IasWd.StrobeLevel + _attr_name = "Default strobe level" @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) @@ -148,6 +152,7 @@ class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity, id_suffix=Strobe.__nam """Representation of a ZHA default siren strobe select entity.""" _enum = Strobe + _attr_name = "Default strobe" class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): @@ -220,6 +225,86 @@ class ZHAStartupOnOffSelectEntity( _select_attr = "start_up_on_off" _enum = OnOff.StartUpOnOff + _attr_name = "Start-up behavior" + + +class TuyaPowerOnState(types.enum8): + """Tuya power on state enum.""" + + Off = 0x00 + On = 0x01 + LastState = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_ON_OFF, + models={"TS011F", "TS0121", "TS0001", "TS0002", "TS0003", "TS0004"}, +) +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="tuya_manufacturer", + manufacturers={ + "_TZE200_7tdtqgwv", + "_TZE200_amp6tsvy", + "_TZE200_oisqyl4o", + "_TZE200_vhy3iakz", + "_TZ3000_uim07oem", + "_TZE200_wfxuhoea", + "_TZE200_tviaymwx", + "_TZE200_g1ib5ldv", + "_TZE200_wunufsil", + "_TZE200_7deq70b8", + "_TZE200_tz32mtza", + "_TZE200_2hf7x9n3", + "_TZE200_aqnazj70", + "_TZE200_1ozguk6x", + "_TZE200_k6jhsr0q", + "_TZE200_9mahtqtg", + }, +) +class TuyaPowerOnStateSelectEntity(ZCLEnumSelectEntity, id_suffix="power_on_state"): + """Representation of a ZHA power on state select entity.""" + + _select_attr = "power_on_state" + _enum = TuyaPowerOnState + _attr_name = "Power on state" + + +class MoesBacklightMode(types.enum8): + """MOES switch backlight mode enum.""" + + Off = 0x00 + LightWhenOn = 0x01 + LightWhenOff = 0x02 + Freeze = 0x03 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="tuya_manufacturer", + manufacturers={ + "_TZE200_7tdtqgwv", + "_TZE200_amp6tsvy", + "_TZE200_oisqyl4o", + "_TZE200_vhy3iakz", + "_TZ3000_uim07oem", + "_TZE200_wfxuhoea", + "_TZE200_tviaymwx", + "_TZE200_g1ib5ldv", + "_TZE200_wunufsil", + "_TZE200_7deq70b8", + "_TZE200_tz32mtza", + "_TZE200_2hf7x9n3", + "_TZE200_aqnazj70", + "_TZE200_1ozguk6x", + "_TZE200_k6jhsr0q", + "_TZE200_9mahtqtg", + }, +) +class MoesBacklightModeSelectEntity(ZCLEnumSelectEntity, id_suffix="backlight_mode"): + """Moes devices have a different backlight mode select options.""" + + _select_attr = "backlight_mode" + _enum = MoesBacklightMode + _attr_name = "Backlight mode" class AqaraMotionSensitivities(types.enum8): @@ -234,10 +319,55 @@ class AqaraMotionSensitivities(types.enum8): 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.""" + """Representation of a ZHA motion sensitivity configuration entity.""" _select_attr = "motion_sensitivity" _enum = AqaraMotionSensitivities + _attr_name = "Motion sensitivity" + + +class HueV1MotionSensitivities(types.enum8): + """Hue v1 motion sensitivities.""" + + Low = 0x00 + Medium = 0x01 + High = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_OCCUPANCY, + manufacturers={"Philips", "Signify Netherlands B.V."}, + models={"SML001"}, +) +class HueV1MotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): + """Representation of a ZHA motion sensitivity configuration entity.""" + + _select_attr = "sensitivity" + _attr_name = "Hue motion sensitivity" + _enum = HueV1MotionSensitivities + + +class HueV2MotionSensitivities(types.enum8): + """Hue v2 motion sensitivities.""" + + Lowest = 0x00 + Low = 0x01 + Medium = 0x02 + High = 0x03 + Highest = 0x04 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_OCCUPANCY, + manufacturers={"Philips", "Signify Netherlands B.V."}, + models={"SML002", "SML003", "SML004"}, +) +class HueV2MotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): + """Representation of a ZHA motion sensitivity configuration entity.""" + + _select_attr = "sensitivity" + _attr_name = "Hue motion sensitivity" + _enum = HueV2MotionSensitivities class AqaraMonitoringModess(types.enum8): @@ -253,6 +383,7 @@ class AqaraMonitoringMode(ZCLEnumSelectEntity, id_suffix="monitoring_mode"): _select_attr = "monitoring_mode" _enum = AqaraMonitoringModess + _attr_name = "Monitoring mode" class AqaraApproachDistances(types.enum8): @@ -269,6 +400,7 @@ class AqaraApproachDistance(ZCLEnumSelectEntity, id_suffix="approach_distance"): _select_attr = "approach_distance" _enum = AqaraApproachDistances + _attr_name = "Approach distance" class AqaraE1ReverseDirection(types.enum8): @@ -286,6 +418,7 @@ class AqaraCurtainMode(ZCLEnumSelectEntity, id_suffix="window_covering_mode"): _select_attr = "window_covering_mode" _enum = AqaraE1ReverseDirection + _attr_name = "Curtain mode" class InovelliOutputMode(types.enum1): diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 74ec924af78e86c5b8edacda70437412018e65f1..ba4aec66f35a10274a463372f7cf2a8ba2784304 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -216,8 +216,9 @@ class Battery(Sensor): SENSOR_ATTR = "battery_percentage_remaining" _attr_device_class: SensorDeviceClass = SensorDeviceClass.BATTERY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _unit = PERCENTAGE _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_name: str = "Battery" + _unit = PERCENTAGE @classmethod def create_entity( @@ -268,6 +269,7 @@ class ElectricalMeasurement(Sensor): _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_should_poll = True # BaseZhaEntity defaults to False _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Active power" _unit = POWER_WATT _div_mul_prefix = "ac_power" @@ -309,6 +311,7 @@ class ElectricalMeasurementApparentPower( SENSOR_ATTR = "apparent_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor + _attr_name: str = "Apparent power" _unit = POWER_VOLT_AMPERE _div_mul_prefix = "ac_power" @@ -320,6 +323,7 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_curr SENSOR_ATTR = "rms_current" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor + _attr_name: str = "RMS current" _unit = ELECTRIC_CURRENT_AMPERE _div_mul_prefix = "ac_current" @@ -331,6 +335,7 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_volt SENSOR_ATTR = "rms_voltage" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor + _attr_name: str = "RMS voltage" _unit = ELECTRIC_POTENTIAL_VOLT _div_mul_prefix = "ac_voltage" @@ -342,6 +347,7 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_freque SENSOR_ATTR = "ac_frequency" _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor + _attr_name: str = "AC frequency" _unit = FREQUENCY_HERTZ _div_mul_prefix = "ac_frequency" @@ -353,6 +359,7 @@ class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_f SENSOR_ATTR = "power_factor" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor + _attr_name: str = "Power factor" _unit = PERCENTAGE @@ -366,6 +373,7 @@ class Humidity(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Humidity" _divisor = 100 _unit = PERCENTAGE @@ -377,6 +385,7 @@ class SoilMoisture(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Soil moisture" _divisor = 100 _unit = PERCENTAGE @@ -388,6 +397,7 @@ class LeafWetness(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Leaf wetness" _divisor = 100 _unit = PERCENTAGE @@ -399,6 +409,7 @@ class Illuminance(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.ILLUMINANCE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Illuminance" _unit = LIGHT_LUX def formatter(self, value: int) -> float: @@ -416,6 +427,7 @@ class SmartEnergyMetering(Sensor): SENSOR_ATTR: int | str = "instantaneous_demand" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Instantaneous demand" unit_of_measure_map = { 0x00: POWER_WATT, @@ -463,6 +475,7 @@ class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered") SENSOR_ATTR: int | str = "current_summ_delivered" _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENERGY _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING + _attr_name: str = "Summation delivered" unit_of_measure_map = { 0x00: ENERGY_KILO_WATT_HOUR, @@ -513,6 +526,7 @@ class Pressure(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.PRESSURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Pressure" _decimals = 0 _unit = PRESSURE_HPA @@ -524,6 +538,7 @@ class Temperature(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Temperature" _divisor = 100 _unit = TEMP_CELSIUS @@ -535,6 +550,7 @@ class DeviceTemperature(Sensor): SENSOR_ATTR = "current_temperature" _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Device temperature" _divisor = 100 _unit = TEMP_CELSIUS _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -547,6 +563,7 @@ class CarbonDioxideConcentration(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO2 _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Carbon dioxide concentration" _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_PARTS_PER_MILLION @@ -559,6 +576,7 @@ class CarbonMonoxideConcentration(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Carbon monoxide concentration" _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_PARTS_PER_MILLION @@ -572,6 +590,7 @@ class VOCLevel(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "VOC level" _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -588,6 +607,7 @@ class PPBVOCLevel(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "VOC level" _decimals = 0 _multiplier = 1 _unit = CONCENTRATION_PARTS_PER_BILLION @@ -599,6 +619,7 @@ class PM25(Sensor): SENSOR_ATTR = "measured_value" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Particulate matter" _decimals = 0 _multiplier = 1 _unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -610,6 +631,7 @@ class FormaldehydeConcentration(Sensor): SENSOR_ATTR = "measured_value" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_name: str = "Formaldehyde concentration" _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_PARTS_PER_MILLION @@ -619,6 +641,8 @@ class FormaldehydeConcentration(Sensor): class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): """Thermostat HVAC action sensor.""" + _attr_name: str = "HVAC action" + @classmethod def create_entity( cls: type[_ThermostatHVACActionSelfT], @@ -744,6 +768,7 @@ class RSSISensor(Sensor, id_suffix="rssi"): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_registry_enabled_default = False _attr_should_poll = True # BaseZhaEntity defaults to False + _attr_name: str = "RSSI" unique_id_suffix: str @classmethod @@ -773,6 +798,8 @@ class RSSISensor(Sensor, id_suffix="rssi"): class LQISensor(RSSISensor, id_suffix="lqi"): """LQI sensor for a device.""" + _attr_name: str = "LQI" + @MULTI_MATCH( channel_names="tuya_manufacturer", @@ -786,6 +813,7 @@ class TimeLeft(Sensor, id_suffix="time_left"): SENSOR_ATTR = "timer_time_left" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" + _attr_name: str = "Time left" _unit = TIME_MINUTES @@ -796,6 +824,7 @@ class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): SENSOR_ATTR = "device_run_time" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" + _attr_name: str = "Device run time" _unit = TIME_MINUTES @@ -806,4 +835,5 @@ class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): SENSOR_ATTR = "filter_run_time" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" + _attr_name: str = "Filter run time" _unit = TIME_MINUTES diff --git a/homeassistant/components/zha/services.yaml b/homeassistant/components/zha/services.yaml index 0e645da365e76445533c72fa570444a6b49cc706..132dae6e745a8203c113623684e63aee663c4ac0 100644 --- a/homeassistant/components/zha/services.yaml +++ b/homeassistant/components/zha/services.yaml @@ -187,6 +187,11 @@ issue_zigbee_cluster_command: example: "[arg1, arg2, argN]" selector: object: + params: + name: Params + description: parameters to pass to the command + selector: + object: manufacturer: name: Manufacturer description: manufacturer code diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 47568648f2ba426aecb456a1895ee68c96df243a..0bd55cdbe684071dbc38b6087522a8f007f3e36f 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( + CHANNEL_BASIC, CHANNEL_INOVELLI, CHANNEL_ON_OFF, DATA_ZHA, @@ -290,6 +291,33 @@ class P1MotionTriggerIndicatorSwitch( """Representation of a ZHA motion triggering configuration entity.""" _zcl_attribute: str = "trigger_indicator" + _attr_name = "LED trigger indicator" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.plug.mmeu01", "lumi.plug.maeu01"} +) +class XiaomiPlugPowerOutageMemorySwitch( + ZHASwitchConfigurationEntity, id_suffix="power_outage_memory" +): + """Representation of a ZHA power outage memory configuration entity.""" + + _zcl_attribute: str = "power_outage_memory" + _attr_name = "Power outage memory" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_BASIC, + manufacturers={"Philips", "Signify Netherlands B.V."}, + models={"SML001", "SML002", "SML003", "SML004"}, +) +class HueMotionTriggerIndicatorSwitch( + ZHASwitchConfigurationEntity, id_suffix="trigger_indicator" +): + """Representation of a ZHA motion triggering configuration entity.""" + + _zcl_attribute: str = "trigger_indicator" + _attr_name = "LED trigger indicator" @CONFIG_DIAGNOSTIC_MATCH( @@ -300,6 +328,7 @@ class ChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): """ZHA BinarySensor.""" _zcl_attribute: str = "child_lock" + _attr_name = "Child lock" @CONFIG_DIAGNOSTIC_MATCH( @@ -310,6 +339,7 @@ class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): """ZHA BinarySensor.""" _zcl_attribute: str = "disable_led" + _attr_name = "Disable LED" @CONFIG_DIAGNOSTIC_MATCH( diff --git a/homeassistant/components/zha/translations/bg.json b/homeassistant/components/zha/translations/bg.json index 3bd7629cd957c495c13fb4bfbedab0da65006323..1c4c44c9dffae443fb6cb8f06b7024b13ad6030e 100644 --- a/homeassistant/components/zha/translations/bg.json +++ b/homeassistant/components/zha/translations/bg.json @@ -7,6 +7,7 @@ "error": { "cannot_connect": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 ZHA \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" }, + "flow_title": "{name}", "step": { "choose_formation_strategy": { "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u043c\u0440\u0435\u0436\u043e\u0432\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0437\u0430 \u0432\u0430\u0448\u0435\u0442\u043e \u0440\u0430\u0434\u0438\u043e.", @@ -61,6 +62,11 @@ } } }, + "config_panel": { + "zha_options": { + "title": "\u0413\u043b\u043e\u0431\u0430\u043b\u043d\u0438 \u043e\u043f\u0446\u0438\u0438" + } + }, "device_automation": { "action_type": { "squawk": "\u041a\u0432\u0430\u043a", @@ -110,7 +116,8 @@ }, "options": { "abort": { - "not_zha_device": "\u0422\u043e\u0432\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0435 zha \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + "not_zha_device": "\u0422\u043e\u0432\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0435 zha \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e", + "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" @@ -132,6 +139,12 @@ "description": "ZHA \u0449\u0435 \u0431\u044a\u0434\u0435 \u0441\u043f\u0440\u044f\u043d. \u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0438\u0442\u0435?", "title": "\u041f\u0440\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 ZHA" }, + "instruct_unplug": { + "title": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u0442\u0435 \u0441\u0442\u0430\u0440\u043e\u0442\u043e \u0441\u0438 \u0440\u0430\u0434\u0438\u043e" + }, + "intent_migrate": { + "title": "\u041c\u0438\u0433\u0440\u0438\u0440\u0430\u043d\u0435 \u043a\u044a\u043c \u043d\u043e\u0432\u043e \u0440\u0430\u0434\u0438\u043e" + }, "manual_pick_radio_type": { "data": { "radio_type": "\u0422\u0438\u043f \u0440\u0430\u0434\u0438\u043e" @@ -146,6 +159,14 @@ "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u0442\u0435 \u043d\u0430 \u0441\u0435\u0440\u0438\u0439\u043d\u0438\u044f \u043f\u043e\u0440\u0442", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0430 \u0441\u0435\u0440\u0438\u0439\u043d\u0438\u044f \u043f\u043e\u0440\u0442" }, + "prompt_migrate_or_reconfigure": { + "description": "\u041c\u0438\u0433\u0440\u0438\u0440\u0430\u0442\u0435 \u043a\u044a\u043c \u043d\u043e\u0432\u043e \u0440\u0430\u0434\u0438\u043e \u0438\u043b\u0438 \u043f\u0440\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 \u0442\u0435\u043a\u0443\u0449\u043e\u0442\u043e \u0440\u0430\u0434\u0438\u043e?", + "menu_options": { + "intent_migrate": "\u041c\u0438\u0433\u0440\u0438\u0440\u0430\u043d\u0435 \u043a\u044a\u043c \u043d\u043e\u0432\u043e \u0440\u0430\u0434\u0438\u043e", + "intent_reconfigure": "\u041f\u0440\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0442\u0435\u043a\u0443\u0449\u043e\u0442\u043e \u0440\u0430\u0434\u0438\u043e" + }, + "title": "\u041c\u0438\u0433\u0440\u0438\u0440\u0430\u043d\u0435 \u0438\u043b\u0438 \u043f\u0440\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "\u041a\u0430\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 \u0444\u0430\u0439\u043b" diff --git a/homeassistant/components/zha/translations/ca.json b/homeassistant/components/zha/translations/ca.json index 073e72b80a353d2a9ffdd6fd7dbe133cd7080e76..db5d77047f5e810092b7b870f502ec46a0efe308 100644 --- a/homeassistant/components/zha/translations/ca.json +++ b/homeassistant/components/zha/translations/ca.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Envia efecte a tots els LEDs", + "issue_individual_led_effect": "Envia efecte a un LED individual", "squawk": "Squawk", "warn": "Av\u00eds" }, @@ -210,6 +212,14 @@ "description": "ZHA s'aturar\u00e0. Vols continuar?", "title": "Reconfiguraci\u00f3 de ZHA" }, + "instruct_unplug": { + "description": "La r\u00e0dio antiga s'ha reiniciat. Si el maquinari ja no \u00e9s necessari, ara pots desconnectar-lo.", + "title": "Desconnecta la r\u00e0dio antiga" + }, + "intent_migrate": { + "description": "La r\u00e0dio antiga es restablir\u00e0 de f\u00e0brica. Si utilitzes un adaptador de Z-Wave i Zigbee combinat com el HUSBZB-1, nom\u00e9s restablir\u00e0 la part del Zigbee. \n\nVols continuar?", + "title": "Migra a una nova r\u00e0dio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Tipus de r\u00e0dio" @@ -233,6 +243,14 @@ "description": "La teva c\u00f2pia de seguretat t\u00e9 una adre\u00e7a IEEE diferent de la teva r\u00e0dio. Perqu\u00e8 la xarxa funcioni correctament, tamb\u00e9 s'ha de canviar l'adre\u00e7a IEEE de la teva r\u00e0dio. \n\nAquesta \u00e9s una operaci\u00f3 permanent.", "title": "Sobreescriu l'adre\u00e7a IEEE r\u00e0dio" }, + "prompt_migrate_or_reconfigure": { + "description": "Est\u00e0s migrant a una r\u00e0dio nova o tornant a configurar la r\u00e0dio actual?", + "menu_options": { + "intent_migrate": "Migra a una nova r\u00e0dio", + "intent_reconfigure": "Torna a configurar la r\u00e0dio actual" + }, + "title": "Migraci\u00f3 o reconfiguraci\u00f3" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Puja un fitxer" diff --git a/homeassistant/components/zha/translations/de.json b/homeassistant/components/zha/translations/de.json index 60ea4fcc615dc88a317dd124444d8b0487bcd68a..5be0add1ae23204086c5451f24781fd62b195e0d 100644 --- a/homeassistant/components/zha/translations/de.json +++ b/homeassistant/components/zha/translations/de.json @@ -61,7 +61,7 @@ "data": { "overwrite_coordinator_ieee": "Dauerhaftes Ersetzen der IEEE-Funkadresse" }, - "description": "Dein Backup hat eine andere IEEE-Adresse als dein Funkger\u00e4t. Damit dein Netzwerk ordnungsgem\u00e4\u00df funktioniert, sollte auch die IEEE-Adresse deines Funkger\u00e4ts ge\u00e4ndert werden.\n\nDies ist ein permanenter Vorgang.", + "description": "Dein Backup hat eine andere IEEE-Adresse als dein Funkger\u00e4t. Damit dein Netzwerk ordnungsgem\u00e4\u00df funktioniert, sollte auch die IEEE-Adresse deines Funkger\u00e4ts ge\u00e4ndert werden.\n\nDies ist ein permanenter Vorgang.", "title": "Funk-IEEE-Adresse \u00fcberschreiben" }, "pick_radio": { @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Ausgabeeffekt f\u00fcr alle LEDs", + "issue_individual_led_effect": "Ausgabeeffekt f\u00fcr einzelne LED", "squawk": "Kreischen", "warn": "Warnen" }, @@ -210,6 +212,14 @@ "description": "ZHA wird gestoppt. M\u00f6chtest du fortfahren?", "title": "ZHA rekonfigurieren" }, + "instruct_unplug": { + "description": "Dein altes Funkger\u00e4t wurde zur\u00fcckgesetzt. Wenn die Hardware nicht mehr ben\u00f6tigt wird, kannst du es jetzt ausstecken.", + "title": "Stecke dein altes Funkger\u00e4t aus" + }, + "intent_migrate": { + "description": "Dein altes Funkger\u00e4t wird auf die Werkseinstellungen zur\u00fcckgesetzt. Wenn du einen kombinierten Z-Wave- und Zigbee-Adapter wie den HUSBZB-1 verwendest, wird nur der Zigbee-Teil zur\u00fcckgesetzt.\n\nM\u00f6chtest du fortfahren?", + "title": "Umstellung auf ein neues Funkger\u00e4t" + }, "manual_pick_radio_type": { "data": { "radio_type": "Funktyp" @@ -230,9 +240,17 @@ "data": { "overwrite_coordinator_ieee": "Dauerhaftes Ersetzen der IEEE-Funkadresse" }, - "description": "Dein Backup hat eine andere IEEE-Adresse als dein Funkger\u00e4t. Damit dein Netzwerk ordnungsgem\u00e4\u00df funktioniert, sollte auch die IEEE-Adresse deines Funkger\u00e4ts ge\u00e4ndert werden.\n\nDies ist ein permanenter Vorgang.", + "description": "Dein Backup hat eine andere IEEE-Adresse als dein Funkger\u00e4t. Damit dein Netzwerk ordnungsgem\u00e4\u00df funktioniert, sollte auch die IEEE-Adresse deines Funkger\u00e4ts ge\u00e4ndert werden.\n\nDies ist ein permanenter Vorgang.", "title": "Funk-IEEE-Adresse \u00fcberschreiben" }, + "prompt_migrate_or_reconfigure": { + "description": "Stellst du auf ein neues Funkger\u00e4t um oder konfigurierst du das aktuelle Funkger\u00e4t neu?", + "menu_options": { + "intent_migrate": "Umstellung auf ein neues Funkger\u00e4t", + "intent_reconfigure": "Das aktuelle Funkger\u00e4t neu konfigurieren" + }, + "title": "Migrieren oder neu konfigurieren" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Datei hochladen" diff --git a/homeassistant/components/zha/translations/el.json b/homeassistant/components/zha/translations/el.json index 69c437a2e0678af95d57e6d19f6e2d21932b7857..2d3ca09560c77ddb1b07d1add79e86b2e2fc6e91 100644 --- a/homeassistant/components/zha/translations/el.json +++ b/homeassistant/components/zha/translations/el.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "\u0395\u03c6\u03ad \u03c0\u03c1\u03bf\u03b2\u03bb\u03ae\u03bc\u03b1\u03c4\u03bf\u03c2 \u03b3\u03b9\u03b1 \u03cc\u03bb\u03b1 \u03c4\u03b1 LED", + "issue_individual_led_effect": "\u0395\u03c6\u03ad \u03c0\u03c1\u03bf\u03b2\u03bb\u03ae\u03bc\u03b1\u03c4\u03bf\u03c2 \u03b3\u03b9\u03b1 \u03bc\u03b5\u03bc\u03bf\u03bd\u03c9\u03bc\u03ad\u03bd\u03b1 LED", "squawk": "\u039a\u03b1\u03ba\u03ac\u03c1\u03b9\u03c3\u03bc\u03b1", "warn": "\u03a0\u03c1\u03bf\u03b5\u03b9\u03b4\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7" }, @@ -210,6 +212,14 @@ "description": "\u03a4\u03bf ZHA \u03b8\u03b1 \u03c3\u03c4\u03b1\u03bc\u03b1\u03c4\u03ae\u03c3\u03b5\u03b9. \u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c3\u03c5\u03bd\u03b5\u03c7\u03af\u03c3\u03b5\u03c4\u03b5;", "title": "\u0395\u03c0\u03b1\u03bd\u03b1\u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 ZHA" }, + "instruct_unplug": { + "description": "\u0388\u03b3\u03b9\u03bd\u03b5 \u03b5\u03c0\u03b1\u03bd\u03b1\u03c6\u03bf\u03c1\u03ac \u03c4\u03bf\u03c5 \u03c0\u03b1\u03bb\u03b9\u03bf\u03cd \u03c3\u03b1\u03c2 \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7. \u0395\u03ac\u03bd \u03c4\u03bf \u03c5\u03bb\u03b9\u03ba\u03cc \u03b4\u03b5\u03bd \u03c7\u03c1\u03b5\u03b9\u03ac\u03b6\u03b5\u03c4\u03b1\u03b9 \u03c0\u03bb\u03ad\u03bf\u03bd, \u03bc\u03c0\u03bf\u03c1\u03b5\u03af\u03c4\u03b5 \u03c4\u03ce\u03c1\u03b1 \u03bd\u03b1 \u03c4\u03bf \u03b1\u03c0\u03bf\u03c3\u03c5\u03bd\u03b4\u03ad\u03c3\u03b5\u03c4\u03b5.", + "title": "\u0391\u03c0\u03bf\u03c3\u03c5\u03bd\u03b4\u03ad\u03c3\u03c4\u03b5 \u03c4\u03bf \u03c0\u03b1\u03bb\u03b9\u03cc \u03c3\u03b1\u03c2 \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7" + }, + "intent_migrate": { + "description": "\u039f \u03c0\u03b1\u03bb\u03b9\u03cc\u03c2 \u03c3\u03b1\u03c2 \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7 \u03b8\u03b1 \u03b5\u03c0\u03b1\u03bd\u03b1\u03c6\u03b5\u03c1\u03b8\u03b5\u03af \u03b1\u03c0\u03cc \u03c4\u03b9\u03c2 \u03b5\u03c1\u03b3\u03bf\u03c3\u03c4\u03b1\u03c3\u03b9\u03b1\u03ba\u03ad\u03c2 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03b9\u03c2. \u0395\u03ac\u03bd \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b5 \u03c3\u03c5\u03bd\u03b4\u03c5\u03b1\u03c3\u03bc\u03ad\u03bd\u03bf \u03c0\u03c1\u03bf\u03c3\u03b1\u03c1\u03bc\u03bf\u03b3\u03ad\u03b1 Z-Wave \u03ba\u03b1\u03b9 Zigbee \u03cc\u03c0\u03c9\u03c2 \u03c4\u03bf HUSBZB-1, \u03b1\u03c5\u03c4\u03cc \u03b8\u03b1 \u03b5\u03c0\u03b1\u03bd\u03b1\u03c6\u03ad\u03c1\u03b5\u03b9 \u03bc\u03cc\u03bd\u03bf \u03c4\u03bf \u03c4\u03bc\u03ae\u03bc\u03b1 Zigbee.\n\n\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c3\u03c5\u03bd\u03b5\u03c7\u03af\u03c3\u03b5\u03c4\u03b5;", + "title": "\u039c\u03b5\u03c4\u03b5\u03b3\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7 \u03c3\u03b5 \u03bd\u03ad\u03bf \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7" + }, "manual_pick_radio_type": { "data": { "radio_type": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03ba\u03b5\u03c1\u03b1\u03af\u03b1\u03c2" @@ -233,6 +243,14 @@ "description": "\u03a4\u03bf \u03b5\u03c6\u03b5\u03b4\u03c1\u03b9\u03ba\u03cc \u03c3\u03b1\u03c2 \u03b1\u03bd\u03c4\u03af\u03b3\u03c1\u03b1\u03c6\u03bf \u03ad\u03c7\u03b5\u03b9 \u03b4\u03b9\u03b1\u03c6\u03bf\u03c1\u03b5\u03c4\u03b9\u03ba\u03ae \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 IEEE \u03b1\u03c0\u03cc \u03c4\u03bf\u03bd \u03b1\u03c3\u03cd\u03c1\u03bc\u03b1\u03c4\u03cc \u03c3\u03b1\u03c2. \u0393\u03b9\u03b1 \u03bd\u03b1 \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03ae\u03c3\u03b5\u03b9 \u03c3\u03c9\u03c3\u03c4\u03ac \u03c4\u03bf \u03b4\u03af\u03ba\u03c4\u03c5\u03cc \u03c3\u03b1\u03c2, \u03b8\u03b1 \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03b1\u03bb\u03bb\u03ac\u03be\u03b5\u03b9 \u03ba\u03b1\u03b9 \u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 IEEE \u03c4\u03bf\u03c5 \u03c1\u03b1\u03b4\u03b9\u03bf\u03c6\u03ce\u03bd\u03bf\u03c5 \u03c3\u03b1\u03c2.\n\n\u0391\u03c5\u03c4\u03ae \u03b5\u03af\u03bd\u03b1\u03b9 \u03bc\u03b9\u03b1 \u03bc\u03cc\u03bd\u03b9\u03bc\u03b7 \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1.", "title": "\u0391\u03bd\u03c4\u03b9\u03ba\u03b1\u03c4\u03b1\u03c3\u03c4\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 IEEE Radio" }, + "prompt_migrate_or_reconfigure": { + "description": "\u03a0\u03c1\u03b1\u03b3\u03bc\u03b1\u03c4\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b5 \u03bc\u03b5\u03c4\u03b5\u03b3\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7 \u03c3\u03b5 \u03bd\u03ad\u03bf \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7 \u03ae \u03c1\u03c5\u03b8\u03bc\u03af\u03b6\u03b5\u03c4\u03b5 \u03b5\u03ba \u03bd\u03ad\u03bf\u03c5 \u03c4\u03b9\u03c2 \u03c0\u03b1\u03c1\u03b1\u03bc\u03ad\u03c4\u03c1\u03bf\u03c5\u03c2 \u03c4\u03bf\u03c5 \u03c4\u03c1\u03ad\u03c7\u03bf\u03bd\u03c4\u03bf\u03c2 \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7;", + "menu_options": { + "intent_migrate": "\u039c\u03b5\u03c4\u03b1\u03c6\u03bf\u03c1\u03ac \u03c3\u03b5 \u03bd\u03ad\u03bf \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7", + "intent_reconfigure": "\u0395\u03c0\u03b1\u03bd\u03b1\u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03c4\u03c1\u03ad\u03c7\u03bf\u03bd\u03c4\u03bf\u03c2 \u03c0\u03bf\u03bc\u03c0\u03bf\u03b4\u03ad\u03ba\u03c4\u03b7" + }, + "title": "\u039c\u03b5\u03c4\u03b5\u03b3\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7 \u03ae \u03b5\u03c0\u03b1\u03bd\u03b1\u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "\u0391\u03bd\u03b5\u03b2\u03ac\u03c3\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03b1\u03c1\u03c7\u03b5\u03af\u03bf" diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index 68d36b7fac7db7a518b83a7b2999fefd8e55ce9d..f624f4d54991df076e2bd3f5a8dd822a44ccc039 100644 --- a/homeassistant/components/zha/translations/en.json +++ b/homeassistant/components/zha/translations/en.json @@ -64,12 +64,35 @@ "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.", "title": "Overwrite Radio IEEE Address" }, + "pick_radio": { + "data": { + "radio_type": "Radio Type" + }, + "description": "Pick a type of your Zigbee radio", + "title": "Radio Type" + }, + "port_config": { + "data": { + "baudrate": "port speed", + "flow_control": "data flow control", + "path": "Serial device path" + }, + "description": "Enter port specific settings", + "title": "Settings" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Upload a file" }, "description": "Restore your network settings from an uploaded backup JSON file. You can download one from a different ZHA installation from **Network Settings**, or use a Zigbee2MQTT `coordinator_backup.json` file.", "title": "Upload a Manual Backup" + }, + "user": { + "data": { + "path": "Serial Device Path" + }, + "description": "Select serial port for Zigbee radio", + "title": "ZHA" } } }, @@ -93,10 +116,10 @@ }, "device_automation": { "action_type": { - "squawk": "Squawk", - "warn": "Warn", "issue_all_led_effect": "Issue effect for all LEDs", - "issue_individual_led_effect": "Issue effect for individual LED" + "issue_individual_led_effect": "Issue effect for individual LED", + "squawk": "Squawk", + "warn": "Warn" }, "trigger_subtype": { "both_buttons": "Both buttons", diff --git a/homeassistant/components/zha/translations/es.json b/homeassistant/components/zha/translations/es.json index 7aa42172d0502f1c6a6ebde43f90b817607668c1..2919302a1ac5a299b1e2aa347a07c6a1efe968c0 100644 --- a/homeassistant/components/zha/translations/es.json +++ b/homeassistant/components/zha/translations/es.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Efecto de emisi\u00f3n para todos los LEDs", + "issue_individual_led_effect": "Efecto de emisi\u00f3n para LED individual", "squawk": "Squawk", "warn": "Advertir" }, @@ -210,6 +212,14 @@ "description": "ZHA se detendr\u00e1. \u00bfDeseas continuar?", "title": "Reconfigurar ZHA" }, + "instruct_unplug": { + "description": "Tu antigua radio ha sido reiniciada. Si ya no necesitas el hardware, puedes desconectarlo ahora.", + "title": "Desconecta tu antigua radio" + }, + "intent_migrate": { + "description": "Tu antigua radio se restablecer\u00e1 de f\u00e1brica. Si est\u00e1s utilizando un adaptador combinado de Z-Wave y Zigbee como el HUSBZB-1, esto solo restablecer\u00e1 la parte de Zigbee. \n\n\u00bfDeseas continuar?", + "title": "Migrar a una nueva radio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Tipo de Radio" @@ -233,6 +243,14 @@ "description": "Tu copia de seguridad tiene una direcci\u00f3n IEEE diferente a la de tu radio. Para que tu red funcione correctamente, tambi\u00e9n debes cambiar la direcci\u00f3n IEEE de tu radio. \n\nEsta es una operaci\u00f3n permanente.", "title": "Sobrescribir la direcci\u00f3n IEEE de la radio" }, + "prompt_migrate_or_reconfigure": { + "description": "\u00bfEst\u00e1s migrando a una nueva radio o volviendo a configurar la radio actual?", + "menu_options": { + "intent_migrate": "Migrar a una nueva radio", + "intent_reconfigure": "Volver a configurar la radio actual" + }, + "title": "Migrar o volver a configurar" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Subir un archivo" diff --git a/homeassistant/components/zha/translations/et.json b/homeassistant/components/zha/translations/et.json index 6d39f4b653964b9b1f157c98c8ec7d030b1c94ca..54cabbe4c821e82542524281d05d6825dba8e9b0 100644 --- a/homeassistant/components/zha/translations/et.json +++ b/homeassistant/components/zha/translations/et.json @@ -6,16 +6,64 @@ "usb_probe_failed": "USB seadme k\u00fcsitlemine eba\u00f5nnestus" }, "error": { - "cannot_connect": "\u00dchendamine nurjus" + "cannot_connect": "\u00dchendamine nurjus", + "invalid_backup_json": "Sobimatu varukoopia JSON" }, "flow_title": "{name}", "step": { + "choose_automatic_backup": { + "data": { + "choose_automatic_backup": "Vali automaatne varundamine" + }, + "description": "Taasta v\u00f5rgu seaded automaatsest varukoopiast", + "title": "Taastamine automaatsest varukoopiast" + }, + "choose_formation_strategy": { + "description": "Vali raadio v\u00f5rguseaded.", + "menu_options": { + "choose_automatic_backup": "Taastamine automaatsest varukoopiast", + "form_new_network": "Kustuta v\u00f5rgu seaded ja moodusta uus v\u00f5rk", + "reuse_settings": "Raadiov\u00f5rgu s\u00e4tete s\u00e4ilitamine", + "upload_manual_backup": "Varukoopia \u00fcleslaadimine" + }, + "title": "V\u00f5rgu moodustamine" + }, + "choose_serial_port": { + "data": { + "path": "Jadaseadme tee" + }, + "description": "Vali Zigbee raadio jadaport", + "title": "Vali jadaport" + }, "confirm": { "description": "Kas soovid seadistada teenust {name} ?" }, "confirm_hardware": { "description": "Kas seadistada {name} ?" }, + "manual_pick_radio_type": { + "data": { + "radio_type": "Seadme raadio t\u00fc\u00fcp" + }, + "description": "Vali Zigbee raadio t\u00fc\u00fcp", + "title": "Seadme raadio t\u00fc\u00fcp" + }, + "manual_port_config": { + "data": { + "baudrate": "pordi kiirus", + "flow_control": "andmevoo juhtimine", + "path": "Jadaseadme tee" + }, + "description": "Sisesta jadapordi s\u00e4tted", + "title": "Jadapordi s\u00e4tted" + }, + "maybe_confirm_ezsp_restore": { + "data": { + "overwrite_coordinator_ieee": "Raadio IEEE-aadressi p\u00fcsiv asendamine" + }, + "description": "Varukoopial on erinev IEEE aadress kui raadiol. V\u00f5rgu n\u00f5uetekohaseks toimimiseks tuleks muuta ka raadio IEEE aadressi.\n\nSee on p\u00fcsiv toiming.", + "title": "Raadio IEEE aadressi \u00fclekirjutamine" + }, "pick_radio": { "data": { "radio_type": "Seadme raadio t\u00fc\u00fcp" @@ -32,6 +80,13 @@ "description": "Sisesta pordispetsiifilised seaded", "title": "Seaded" }, + "upload_manual_backup": { + "data": { + "uploaded_backup_file": "Faili \u00fcleslaadimine" + }, + "description": "Taasta oma v\u00f5rgus\u00e4tted \u00fcleslaaditud JSON-varufailist. Saad selle alla laadida teisest ZHA paigaldusest **V\u00f5rguseaded** v\u00f5i kasutada Zigbee2MQTT 'coordinator_backup.json' faili.", + "title": "K\u00e4sitsi varundamise \u00fcleslaadimine" + }, "user": { "data": { "path": "Jadaseadme tee" @@ -61,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "K\u00f5igi LED-ide efekt", + "issue_individual_led_effect": "Efekt \u00fcksikute LEDide puhul", "squawk": "Pr\u00e4\u00e4ksata", "warn": "Hoiata" }, @@ -116,8 +173,22 @@ } }, "options": { + "abort": { + "not_zha_device": "See ei ole zha seade", + "single_instance_allowed": "Juba h\u00e4\u00e4lestatud. V\u00f5imalik on ainult \u00fcks sidumine.", + "usb_probe_failed": "USB seadme k\u00fcsitlemine nurjus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_backup_json": "Sobimatu JSON varundus kirje" + }, + "flow_title": "{name}", "step": { "choose_automatic_backup": { + "data": { + "choose_automatic_backup": "Vali automaatse varunduse kirje" + }, + "description": "Taasta v\u00f5rgus\u00e4tted automaatvarunduse kirjest", "title": "Taasta automaatvarundusest" }, "choose_formation_strategy": { @@ -141,6 +212,14 @@ "description": "ZHA peatatakse. Kas soovid j\u00e4tkata?", "title": "Seadista ZHA uuesti" }, + "instruct_unplug": { + "description": "Teie vana raadio on l\u00e4htestatud. Kui riistvara pole enam vaja, saad selle n\u00fc\u00fcd lahti \u00fchendada.", + "title": "\u00dchenda vana raadio lahti" + }, + "intent_migrate": { + "description": "Vana raadio l\u00e4htestatakse tehaseseadetele. Kui kasutad kombineeritud Z-Wave ja Zigbee adapterit, n\u00e4iteks HUSBZB-1, l\u00e4htestab see ainult Zigbee osa. \n\n Kas soovid j\u00e4tkata?", + "title": "Teisalda uuele seadmele" + }, "manual_pick_radio_type": { "data": { "radio_type": "Raadio t\u00fc\u00fcp" @@ -161,12 +240,22 @@ "data": { "overwrite_coordinator_ieee": "Asenda IEEE aadress j\u00e4\u00e4davalt" }, + "description": "Varukoopial on erinev IEEE aadress kui raadiol. V\u00f5rgu n\u00f5uetekohaseks toimimiseks tuleks muuta ka raadio IEEE aadressi.\n\nSee on p\u00fcsiv toiming.", "title": "Kirjuta IEEE aadress \u00fcle" }, + "prompt_migrate_or_reconfigure": { + "description": "Kas l\u00e4hed \u00fcle uuele raadiole v\u00f5i seadistad praegust raadiot \u00fcmber?", + "menu_options": { + "intent_migrate": "Teisalda uuele seadmele", + "intent_reconfigure": "Taasseadista praegune seade" + }, + "title": "Teisaldamine v\u00f5i uuesti seadistamine" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Lae kirje \u00fcles" }, + "description": "Taasta oma v\u00f5rgus\u00e4tted \u00fcleslaaditud JSON-varufailist. Saad selle alla laadida teisest ZHA paigaldusest **V\u00f5rguseaded** v\u00f5i kasutada Zigbee2MQTT 'coordinator_backup.json' faili.", "title": "Lae k\u00e4sitsi loodud varukoopia \u00fcles" } } diff --git a/homeassistant/components/zha/translations/he.json b/homeassistant/components/zha/translations/he.json index fa40de672e29ae7758704d28a695c765cb3ccee5..f48b30bd826c3af65d2f2ac16d8fd565bff3c87b 100644 --- a/homeassistant/components/zha/translations/he.json +++ b/homeassistant/components/zha/translations/he.json @@ -11,6 +11,12 @@ "port_config": { "title": "\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea" }, + "upload_manual_backup": { + "data": { + "uploaded_backup_file": "\u05d4\u05e2\u05dc\u05d0\u05ea \u05e7\u05d5\u05d1\u05e5" + }, + "title": "\u05d4\u05e2\u05dc\u05d0\u05ea \u05d2\u05d9\u05d1\u05d5\u05d9 \u05d9\u05d3\u05e0\u05d9" + }, "user": { "title": "ZHA" } @@ -46,5 +52,22 @@ "device_dropped": "\u05d4\u05d4\u05ea\u05e7\u05df \u05d4\u05d5\u05e9\u05de\u05d8", "device_offline": "\u05d4\u05ea\u05e7\u05df \u05dc\u05d0 \u05de\u05e7\u05d5\u05d5\u05df" } + }, + "options": { + "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." + }, + "flow_title": "{name}", + "step": { + "init": { + "title": "\u05d4\u05d2\u05d3\u05e8\u05d4 \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc ZHA" + }, + "upload_manual_backup": { + "data": { + "uploaded_backup_file": "\u05d4\u05e2\u05dc\u05d0\u05ea \u05e7\u05d5\u05d1\u05e5" + }, + "title": "\u05d4\u05e2\u05dc\u05d0\u05ea \u05d2\u05d9\u05d1\u05d5\u05d9 \u05d9\u05d3\u05e0\u05d9" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/zha/translations/hu.json b/homeassistant/components/zha/translations/hu.json index 9061246043a10f91188b96380ddae121463fb3bc..b70bfcd597b16fc777d67b49c5769b9ce76de9b2 100644 --- a/homeassistant/components/zha/translations/hu.json +++ b/homeassistant/components/zha/translations/hu.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Effekt minden LED-re", + "issue_individual_led_effect": "Effekt egyes LED-ekre", "squawk": "Riaszt\u00e1s", "warn": "Figyelmeztet\u00e9s" }, @@ -210,6 +212,14 @@ "description": "A ZHA le\u00e1ll. Biztos benne, hogy folytatja?", "title": "A ZHA \u00fajrakonfigur\u00e1l\u00e1sa" }, + "instruct_unplug": { + "description": "A r\u00e9gi r\u00e1di\u00f3t vissza lett \u00e1ll\u00edtva Ha a hardverre m\u00e1r nincs sz\u00fcks\u00e9g, most kih\u00fazhatja.", + "title": "H\u00fazza ki a r\u00e9gi r\u00e1di\u00f3t" + }, + "intent_migrate": { + "description": "A r\u00e9gi r\u00e1di\u00f3ja gy\u00e1ri alaphelyzetbe ker\u00fcl. Ha kombin\u00e1lt Z-Wave \u00e9s Zigbee adaptert haszn\u00e1l, mint p\u00e9ld\u00e1ul a HUSBZB-1, akkor ez csak a Zigbee r\u00e9szt \u00e1ll\u00edtja vissza.\n\nSzeretn\u00e9 folytatni?", + "title": "\u00daj r\u00e1di\u00f3ra val\u00f3 \u00e1tt\u00e9r\u00e9s" + }, "manual_pick_radio_type": { "data": { "radio_type": "R\u00e1di\u00f3 t\u00edpusa" @@ -233,6 +243,14 @@ "description": "A biztons\u00e1gi m\u00e1solat IEEE-c\u00edme elt\u00e9r a r\u00e1di\u00f3\u00e9t\u00f3l. A h\u00e1l\u00f3zat megfelel\u0151 m\u0171k\u00f6d\u00e9s\u00e9hez a r\u00e1di\u00f3 IEEE-c\u00edm\u00e9t is meg kell v\u00e1ltoztatni. \n\n Ez egy v\u00e9gleles m\u0171velet.", "title": "A r\u00e1di\u00f3 IEEE-c\u00edm\u00e9nek fel\u00fcl\u00edr\u00e1sa" }, + "prompt_migrate_or_reconfigure": { + "description": "\u00daj r\u00e1di\u00f3ra val\u00f3 \u00e1tt\u00e9r\u00e9s vagy a jelenlegi r\u00e1di\u00f3 \u00fajrakonfigur\u00e1l\u00e1sa?", + "menu_options": { + "intent_migrate": "\u00daj r\u00e1di\u00f3ra val\u00f3 \u00e1tt\u00e9r\u00e9s", + "intent_reconfigure": "Az aktu\u00e1lis r\u00e1di\u00f3 \u00fajrakonfigur\u00e1l\u00e1sa" + }, + "title": "Migr\u00e1l\u00e1s vagy \u00fajrakonfigur\u00e1l\u00e1s" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "F\u00e1jl felt\u00f6lt\u00e9se" diff --git a/homeassistant/components/zha/translations/id.json b/homeassistant/components/zha/translations/id.json index 65f7588cb609d26462f53387c34c5d3ad1074efd..ab496b3be5324825018b0bf4d1e2ae81e1e94d75 100644 --- a/homeassistant/components/zha/translations/id.json +++ b/homeassistant/components/zha/translations/id.json @@ -210,6 +210,14 @@ "description": "ZHA akan dihentikan. Ingin melanjutkan?", "title": "Konfigurasi Ulang ZHA" }, + "instruct_unplug": { + "description": "Radio lama Anda telah disetel ulang. Jika perangkat keras tidak lagi diperlukan, Anda dapat mencabutnya sekarang.", + "title": "Cabut radio lama Anda" + }, + "intent_migrate": { + "description": "Radio lama Anda akan disetel ulang ke setelan pabrikan. Jika Anda menggunakan adaptor gabungan Z-Wave dan Zigbee seperti HUSBZB-1, ini hanya akan mengatur ulang bagian Zigbee.\n\nApakah Anda ingin melanjutkan?", + "title": "Migrasikan ke radio baru" + }, "manual_pick_radio_type": { "data": { "radio_type": "Jenis Radio" @@ -233,6 +241,14 @@ "description": "Cadangan Anda memiliki alamat IEEE yang berbeda dari radio Anda. Agar jaringan berfungsi dengan baik, alamat IEEE radio Anda juga harus diubah.\n\nOperasi ini bersifat permanen.", "title": "Timpa Alamat IEEE Radio" }, + "prompt_migrate_or_reconfigure": { + "description": "Apakah Anda memigrasikan ke radio baru atau mengkonfigurasi ulang radio yang sekarang?", + "menu_options": { + "intent_migrate": "Migrasikan ke radio baru", + "intent_reconfigure": "Mengkonfigurasi ulang radio yang sekarang" + }, + "title": "Migrasi atau konfigurasi ulang" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Unggah file" diff --git a/homeassistant/components/zha/translations/it.json b/homeassistant/components/zha/translations/it.json index 7ff1fc354baba6f05ada9e9a6258d0747551ddf0..02b3549d263c53685035972ed4ba720493d58793 100644 --- a/homeassistant/components/zha/translations/it.json +++ b/homeassistant/components/zha/translations/it.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Effetto di emissione per tutti i LED", + "issue_individual_led_effect": "Effetto di emissione per i singoli LED", "squawk": "Strillare", "warn": "Avvertire" }, @@ -210,6 +212,14 @@ "description": "ZHA verr\u00e0 interrotto. Vuoi continuare?", "title": "Riconfigura ZHA" }, + "instruct_unplug": { + "description": "La tua vecchia radio \u00e8 stata ripristinata. Se l'hardware non \u00e8 pi\u00f9 necessario, ora \u00e8 possibile scollegarlo.", + "title": "Scollega la tua vecchia radio" + }, + "intent_migrate": { + "description": "La tua vecchia radio verr\u00e0 ripristinata alle impostazioni di fabbrica. Se stai usando un adattatore combinato Z-Wave e Zigbee come HUSBZB-1, questo ripristiner\u00e0 solo la parte Zigbee. \n\nVuoi continuare?", + "title": "Migra a una nuova radio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Tipo di radio" @@ -233,6 +243,14 @@ "description": "Il tuo backup ha un indirizzo IEEE diverso dalla tua radio. Affinch\u00e9 la rete funzioni correttamente, \u00e8 necessario modificare anche l'indirizzo IEEE della radio. \n\nQuesta \u00e8 un'operazione permanente.", "title": "Sovrascrivi indirizzo IEEE radio" }, + "prompt_migrate_or_reconfigure": { + "description": "Stai migrando a una nuova radio o riconfigurando la radio attuale?", + "menu_options": { + "intent_migrate": "Migra a una nuova radio", + "intent_reconfigure": "Riconfigura la radio attuale" + }, + "title": "Migrare o riconfigurare" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Carica un file" diff --git a/homeassistant/components/zha/translations/nl.json b/homeassistant/components/zha/translations/nl.json index dacddd201d941951da1690d0209dff22374f7250..e408a9949bc9cbc2ef711193ef5417e380e6b67e 100644 --- a/homeassistant/components/zha/translations/nl.json +++ b/homeassistant/components/zha/translations/nl.json @@ -6,19 +6,35 @@ "usb_probe_failed": "Kon het USB apparaat niet onderzoeken" }, "error": { - "cannot_connect": "Kan geen verbinding maken" + "cannot_connect": "Kan geen verbinding maken", + "invalid_backup_json": "Ongeldige back-up-JSON" }, "flow_title": "{name}", "step": { "choose_automatic_backup": { "title": "Automatische back-up herstellen" }, + "choose_formation_strategy": { + "menu_options": { + "choose_automatic_backup": "Een automatische back-up herstellen", + "upload_manual_backup": "Upload een handmatige back-up" + } + }, + "choose_serial_port": { + "description": "Selecteer de seri\u00eble poort voor je Zigbee-radio", + "title": "Selecteer een seri\u00eble poort" + }, "confirm": { "description": "Wilt u {name} instellen?" }, "confirm_hardware": { "description": "Wilt u {name} instellen?" }, + "manual_port_config": { + "data": { + "baudrate": "poortsnelheid" + } + }, "pick_radio": { "data": { "radio_type": "Radio type" @@ -127,17 +143,33 @@ "usb_probe_failed": "Kon het USB apparaat niet onderzoeken" }, "error": { - "cannot_connect": "Kan geen verbinding maken" + "cannot_connect": "Kan geen verbinding maken", + "invalid_backup_json": "Ongeldige back-up-JSON" }, "flow_title": "{name}", "step": { "choose_automatic_backup": { "title": "Automatische back-up herstellen" }, + "choose_formation_strategy": { + "menu_options": { + "choose_automatic_backup": "Een automatische back-up herstellen", + "upload_manual_backup": "Upload een handmatige back-up" + } + }, + "choose_serial_port": { + "description": "Selecteer de seri\u00eble poort voor je Zigbee-radio", + "title": "Selecteer een seri\u00eble poort" + }, "init": { "description": "ZHA wordt gestopt. Wilt u doorgaan?", "title": "ZHA opnieuw configureren" }, + "manual_port_config": { + "data": { + "baudrate": "poortsnelheid" + } + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Een bestand uploaden" diff --git a/homeassistant/components/zha/translations/no.json b/homeassistant/components/zha/translations/no.json index c469eb54bd7eb1aab070eabcfdaec9eb6d812fb4..989409f7436ae8af253e5a8dde13ca8fd66663e6 100644 --- a/homeassistant/components/zha/translations/no.json +++ b/homeassistant/components/zha/translations/no.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Utstedelseseffekt for alle lysdioder", + "issue_individual_led_effect": "Utstedelseseffekt for individuell LED", "squawk": "Squawk", "warn": "Advare" }, @@ -210,6 +212,14 @@ "description": "ZHA vil bli stoppet. \u00d8nsker du \u00e5 fortsette?", "title": "Konfigurer ZHA p\u00e5 nytt" }, + "instruct_unplug": { + "description": "Den gamle radioen din er tilbakestilt. Hvis maskinvaren ikke lenger er n\u00f8dvendig, kan du n\u00e5 koble den fra.", + "title": "Koble fra den gamle radioen" + }, + "intent_migrate": { + "description": "Den gamle radioen blir tilbakestilt til fabrikkstandard. Hvis du bruker en kombinert Z-Wave og Zigbee-adapter som HUSBZB-1, vil dette bare tilbakestille Zigbee-delen. \n\n \u00d8nsker du \u00e5 fortsette?", + "title": "Migrer til en ny radio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Radio type" @@ -233,6 +243,14 @@ "description": "Sikkerhetskopien din har en annen IEEE-adresse enn radioen din. For at nettverket skal fungere ordentlig, b\u00f8r IEEE-adressen til radioen ogs\u00e5 endres. \n\n Dette er en permanent operasjon.", "title": "Overskriv radio IEEE-adresse" }, + "prompt_migrate_or_reconfigure": { + "description": "Migrerer du til en ny radio eller rekonfigurerer den n\u00e5v\u00e6rende radioen?", + "menu_options": { + "intent_migrate": "Migrer til en ny radio", + "intent_reconfigure": "Konfigurer gjeldende radio p\u00e5 nytt" + }, + "title": "Migrer eller rekonfigurer" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Last opp en fil" diff --git a/homeassistant/components/zha/translations/pl.json b/homeassistant/components/zha/translations/pl.json index 6ac69d568a7d344e0bb807d7898f7fd20fe5db92..b2046848f0abd6a732f78b229f2fb5ef263c1754 100644 --- a/homeassistant/components/zha/translations/pl.json +++ b/homeassistant/components/zha/translations/pl.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Efekt dla wszystkich LED-\u00f3w", + "issue_individual_led_effect": "Efekt dla poszczeg\u00f3lnych LED-\u00f3w", "squawk": "squawk", "warn": "ostrze\u017cenie" }, @@ -210,6 +212,14 @@ "description": "ZHA zostanie zatrzymany. Czy chcesz kontynuowa\u0107?", "title": "Zmiana konfiguracji ZHA" }, + "instruct_unplug": { + "description": "Tw\u00f3j stary typ radia zosta\u0142 zresetowany. Je\u015bli sprz\u0119t nie jest ju\u017c potrzebny, mo\u017cesz go teraz od\u0142\u0105czy\u0107.", + "title": "Od\u0142\u0105cz stary typ radia" + }, + "intent_migrate": { + "description": "Twoje stare radio zostanie zresetowane do ustawie\u0144 fabrycznych. Je\u015bli u\u017cywasz po\u0142\u0105czonego adaptera Z-Wave i Zigbee, takiego jak HUSBZB-1, zresetuje to tylko cz\u0119\u015b\u0107 Zigbee. \n\nCzy chcesz kontynuowa\u0107?", + "title": "Migracja do nowego typu radia" + }, "manual_pick_radio_type": { "data": { "radio_type": "Typ radia" @@ -233,6 +243,14 @@ "description": "Twoja kopia zapasowa ma inny adres IEEE ni\u017c twoje radio. Aby sie\u0107 dzia\u0142a\u0142a prawid\u0142owo, nale\u017cy r\u00f3wnie\u017c zmieni\u0107 adres IEEE radia. \n\nTo jest trwa\u0142a operacja.", "title": "Nadpisanie adresu IEEE radia" }, + "prompt_migrate_or_reconfigure": { + "description": "Czy migrujesz do nowego typu radia czy ponownie konfigurujesz obecne radio?", + "menu_options": { + "intent_migrate": "Migracja do nowego", + "intent_reconfigure": "Ponowna konfiguracja" + }, + "title": "Migracja czy ponowna konfiguracja" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Prze\u015blij plik" diff --git a/homeassistant/components/zha/translations/pt-BR.json b/homeassistant/components/zha/translations/pt-BR.json index 2ec2b97438aa48c3a03878bfb7e4767133178e7d..ba0ac930f1e67bcf3f49960c6f9f276b321031d6 100644 --- a/homeassistant/components/zha/translations/pt-BR.json +++ b/homeassistant/components/zha/translations/pt-BR.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Efeito de emiss\u00e3o para todos os LEDs", + "issue_individual_led_effect": "Efeito de emiss\u00e3o para LED individual", "squawk": "Squawk", "warn": "Aviso" }, @@ -210,6 +212,14 @@ "description": "ZHA ser\u00e1 interrompido. Voc\u00ea deseja continuar?", "title": "Reconfigurar ZHA" }, + "instruct_unplug": { + "description": "Seu r\u00e1dio antigo foi reiniciado. Se o hardware n\u00e3o for mais necess\u00e1rio, agora voc\u00ea pode desconect\u00e1-lo.", + "title": "Desconecte seu r\u00e1dio antigo" + }, + "intent_migrate": { + "description": "Seu r\u00e1dio antigo ser\u00e1 redefinido de f\u00e1brica. Se voc\u00ea estiver usando um adaptador Z-Wave e Zigbee combinado, como o HUSBZB-1, isso apenas redefinir\u00e1 a parte Zigbee. \n\n Voc\u00ea deseja continuar?", + "title": "Migrar para um novo r\u00e1dio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Tipo de r\u00e1dio" @@ -233,6 +243,14 @@ "description": "Seu backup tem um endere\u00e7o IEEE diferente do seu r\u00e1dio. Para que sua rede funcione corretamente, o endere\u00e7o IEEE do seu r\u00e1dio tamb\u00e9m deve ser alterado. \n\n Esta \u00e9 uma opera\u00e7\u00e3o permanente.", "title": "Sobrescrever o endere\u00e7o IEEE do r\u00e1dio" }, + "prompt_migrate_or_reconfigure": { + "description": "Voc\u00ea est\u00e1 migrando para um novo r\u00e1dio ou reconfigurando o r\u00e1dio atual?", + "menu_options": { + "intent_migrate": "Migrar para um novo r\u00e1dio", + "intent_reconfigure": "Reconfigure o r\u00e1dio atual" + }, + "title": "Migrar ou reconfigurar" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Carregar um arquivo" diff --git a/homeassistant/components/zha/translations/ru.json b/homeassistant/components/zha/translations/ru.json index fee8080d8eb8f3661a1922ded7bd0fb4a4dc9bca..a8e58f3ecd9fb060770e21bb86bd13fddb97242d 100644 --- a/homeassistant/components/zha/translations/ru.json +++ b/homeassistant/components/zha/translations/ru.json @@ -210,6 +210,14 @@ "description": "\u0420\u0430\u0431\u043e\u0442\u0430 ZHA \u0431\u0443\u0434\u0435\u0442 \u043e\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0430. \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c?", "title": "\u041f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 ZHA" }, + "instruct_unplug": { + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0412\u0430\u0448\u0435\u0433\u043e \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044f \u0431\u044b\u043b\u0438 \u0441\u0431\u0440\u043e\u0448\u0435\u043d\u044b. \u0415\u0441\u043b\u0438 \u044d\u0442\u043e \u043e\u0431\u043e\u0440\u0443\u0434\u043e\u0432\u0430\u043d\u0438\u0435 \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0435 \u043d\u0443\u0436\u043d\u043e, \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0435\u0433\u043e.", + "title": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0441\u0442\u0430\u0440\u043e\u0433\u043e \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044f" + }, + "intent_migrate": { + "description": "\u0412\u0430\u0448 \u0441\u0442\u0430\u0440\u044b\u0439 \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044c \u0431\u0443\u0434\u0435\u0442 \u0441\u0431\u0440\u043e\u0448\u0435\u043d \u043a \u0437\u0430\u0432\u043e\u0434\u0441\u043a\u0438\u043c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u043c. \u0415\u0441\u043b\u0438 \u0412\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0435 \u043a\u043e\u043c\u0431\u0438\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439 \u0430\u0434\u0430\u043f\u0442\u0435\u0440 Z-Wave \u0438 Zigbee (\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440 HUSBZB-1), \u0431\u0443\u0434\u0443\u0442 \u0441\u0431\u0440\u043e\u0448\u0435\u043d\u044b \u0442\u043e\u043b\u044c\u043a\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Zigbee.\n\n\u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c?", + "title": "\u041f\u0435\u0440\u0435\u0445\u043e\u0434 \u043d\u0430 \u0434\u0440\u0443\u0433\u043e\u0439 \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044c" + }, "manual_pick_radio_type": { "data": { "radio_type": "\u0422\u0438\u043f \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044f" @@ -233,6 +241,14 @@ "description": "\u0412 \u0412\u0430\u0448\u0435\u0439 \u0440\u0435\u0437\u0435\u0440\u0432\u043d\u043e\u0439 \u043a\u043e\u043f\u0438\u0438 IEEE-\u0430\u0434\u0440\u0435\u0441 \u043e\u0442\u043b\u0438\u0447\u0430\u0435\u0442\u0441\u044f \u043e\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u043e\u0433\u043e \u0441\u0435\u0439\u0447\u0430\u0441. \u0427\u0442\u043e\u0431\u044b \u0412\u0430\u0448\u0430 \u0441\u0435\u0442\u044c \u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043b\u0430 \u0434\u043e\u043b\u0436\u043d\u044b\u043c \u043e\u0431\u0440\u0430\u0437\u043e\u043c, IEEE-\u0430\u0434\u0440\u0435\u0441 \u0412\u0430\u0448\u0435\u0433\u043e \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044f \u0442\u0430\u043a\u0436\u0435 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0438\u0437\u043c\u0435\u043d\u0435\u043d. \n\n\u042d\u0442\u043e \u043d\u0435\u043e\u0431\u0440\u0430\u0442\u0438\u043c\u0430\u044f \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u044f.", "title": "\u041f\u0435\u0440\u0435\u0437\u0430\u043f\u0438\u0441\u044c IEEE-\u0430\u0434\u0440\u0435\u0441\u0430 \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044f" }, + "prompt_migrate_or_reconfigure": { + "description": "\u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0439 \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044c \u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u0439\u0442\u0438 \u043d\u0430 \u0434\u0440\u0443\u0433\u043e\u0439?", + "menu_options": { + "intent_migrate": "\u041f\u0435\u0440\u0435\u0439\u0442\u0438 \u043d\u0430 \u0434\u0440\u0443\u0433\u043e\u0439 \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044c", + "intent_reconfigure": "\u041f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0439 \u0440\u0430\u0434\u0438\u043e\u043c\u043e\u0434\u0443\u043b\u044c" + }, + "title": "\u041f\u0435\u0440\u0435\u0445\u043e\u0434 \u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "\u0417\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c \u0444\u0430\u0439\u043b" diff --git a/homeassistant/components/zha/translations/sv.json b/homeassistant/components/zha/translations/sv.json index ca9fc90f5e9e19309d42e2485c5510d409554fa2..a95dc2970fcb9ea05a72cf58c926fda18408a810 100644 --- a/homeassistant/components/zha/translations/sv.json +++ b/homeassistant/components/zha/translations/sv.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "Effekt f\u00f6r alla lysdioder", + "issue_individual_led_effect": "Effekt f\u00f6r enskilda lysdioder", "squawk": "Kraxa", "warn": "Varna" }, @@ -210,6 +212,14 @@ "description": "ZHA kommer att stoppas. Vill du forts\u00e4tta?", "title": "Konfigurera om ZHA" }, + "instruct_unplug": { + "description": "Din gamla radio har blivit fabriks\u00e5terst\u00e4lld. Om h\u00e5rdvaran inte l\u00e4ngre beh\u00f6vs kan du plugga ur den.", + "title": "Koppla ur din gamla radio" + }, + "intent_migrate": { + "description": "Din gamla radio blir fabriks\u00e5terst\u00e4lld. Om du anv\u00e4nder en kombinerad Z-Wave och Zigbee adapter som exempelvis HUSBZB-1, kommer enbart Zigbee delen \u00e5terst\u00e4llas.\n\nVill du forts\u00e4tta?", + "title": "Migrera till ny radio" + }, "manual_pick_radio_type": { "data": { "radio_type": "Radiotyp" @@ -233,6 +243,14 @@ "description": "Din s\u00e4kerhetskopia har en annan IEEE-adress \u00e4n din radio. F\u00f6r att ditt n\u00e4tverk ska fungera korrekt b\u00f6r IEEE-adressen f\u00f6r din radio ocks\u00e5 \u00e4ndras. \n\n Detta \u00e4r en permanent \u00e5tg\u00e4rd.", "title": "Skriv \u00f6ver Radio IEEE-adress" }, + "prompt_migrate_or_reconfigure": { + "description": "Migrerar du till ny radio eller omkonfigurerar du nuvarande radio?", + "menu_options": { + "intent_migrate": "Migrera till ny radio", + "intent_reconfigure": "Omkonfigurera nuvarande radio" + }, + "title": "Migrera eller omkonfigurera" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Ladda upp en fil" diff --git a/homeassistant/components/zha/translations/tr.json b/homeassistant/components/zha/translations/tr.json index 3d3859f745c21f0536ae9e0d2d93b3e713add45d..f309ecf92a044eece033d9d94399a0c455e1ac67 100644 --- a/homeassistant/components/zha/translations/tr.json +++ b/homeassistant/components/zha/translations/tr.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "T\u00fcm LED'ler i\u00e7in sorun efekti", + "issue_individual_led_effect": "Bireysel LED i\u00e7in sorun efekti", "squawk": "Squawk", "warn": "Uyarmak" }, @@ -210,6 +212,14 @@ "description": "ZHA durdurulacak. Devam etmek istiyor musunuz?", "title": "ZHA'y\u0131 yeniden yap\u0131land\u0131r\u0131n" }, + "instruct_unplug": { + "description": "Eski radyonuz s\u0131f\u0131rland\u0131. Donan\u0131m art\u0131k gerekli de\u011filse, \u015fimdi \u00e7\u0131kartabilirsiniz.", + "title": "Eski radyonuzu \u00e7\u0131kart\u0131n" + }, + "intent_migrate": { + "description": "Eski radyonuz fabrika ayarlar\u0131na s\u0131f\u0131rlanacak. HUSBZB-1 gibi birle\u015fik bir Z-Wave ve Zigbee adapt\u00f6r\u00fc kullan\u0131yorsan\u0131z, bu yaln\u0131zca Zigbee k\u0131sm\u0131n\u0131 s\u0131f\u0131rlayacakt\u0131r. \n\n Devam etmek istiyor musunuz?", + "title": "Yeni bir radyoya ge\u00e7i\u015f yap\u0131n" + }, "manual_pick_radio_type": { "data": { "radio_type": "Radyo Tipi" @@ -233,6 +243,14 @@ "description": "Yedeklemenizin, telsizinizden farkl\u0131 bir IEEE adresi var. A\u011f\u0131n\u0131z\u0131n d\u00fczg\u00fcn \u00e7al\u0131\u015fmas\u0131 i\u00e7in telsizinizin IEEE adresinin de de\u011fi\u015ftirilmesi gerekir. \n\n Bu kal\u0131c\u0131 bir operasyondur.", "title": "Radyo IEEE Adresinin \u00dczerine Yaz" }, + "prompt_migrate_or_reconfigure": { + "description": "Yeni bir radyoya m\u0131 ge\u00e7iyorsunuz yoksa mevcut radyoyu yeniden mi yap\u0131land\u0131r\u0131yorsunuz?", + "menu_options": { + "intent_migrate": "Yeni bir radyoya ge\u00e7i\u015f yap\u0131n", + "intent_reconfigure": "Mevcut radyoyu yeniden yap\u0131land\u0131r\u0131n" + }, + "title": "Ta\u015f\u0131ma veya yeniden yap\u0131land\u0131rma" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "Bir dosya y\u00fckleyin" diff --git a/homeassistant/components/zha/translations/zh-Hans.json b/homeassistant/components/zha/translations/zh-Hans.json index ab4b69efbb031850b93809f9be1582e3b8480fc4..c2b8d9f7cc2f9a3b979d831f8186854b54ad647f 100644 --- a/homeassistant/components/zha/translations/zh-Hans.json +++ b/homeassistant/components/zha/translations/zh-Hans.json @@ -87,5 +87,20 @@ "remote_button_short_release": "\"{subtype}\" \u677e\u5f00", "remote_button_triple_press": "\"{subtype}\" \u4e09\u8fde\u51fb" } + }, + "options": { + "step": { + "intent_migrate": { + "title": "\u8fc1\u79fb\u5230\u65b0\u7684\u65e0\u7ebf\u7535\u8bbe\u7f6e" + }, + "prompt_migrate_or_reconfigure": { + "description": "\u60a8\u662f\u5426\u6b63\u5728\u8fc1\u79fb\u5230\u65b0\u65e0\u7ebf\u7535\u6216\u91cd\u65b0\u914d\u7f6e\u5f53\u524d\u65e0\u7ebf\u7535\uff1f", + "menu_options": { + "intent_migrate": "\u8fc1\u79fb\u5230\u65b0\u7684\u65e0\u7ebf\u7535\u8bbe\u7f6e", + "intent_reconfigure": "\u91cd\u65b0\u914d\u7f6e\u5f53\u524d\u65e0\u7ebf\u7535" + }, + "title": "\u8fc1\u79fb\u6216\u91cd\u65b0\u914d\u7f6e" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/zha/translations/zh-Hant.json b/homeassistant/components/zha/translations/zh-Hant.json index 0fbd233bc60f7c8281ac11f347907a981909d27d..23d64c8cc4240c63b0348d9d153cf0598f959a28 100644 --- a/homeassistant/components/zha/translations/zh-Hant.json +++ b/homeassistant/components/zha/translations/zh-Hant.json @@ -116,6 +116,8 @@ }, "device_automation": { "action_type": { + "issue_all_led_effect": "\u57f7\u884c\u5168\u90e8 LED \u6548\u679c", + "issue_individual_led_effect": "\u57f7\u884c\u500b\u5225 LED \u6548\u679c", "squawk": "\u61c9\u7b54", "warn": "\u8b66\u544a" }, @@ -210,6 +212,14 @@ "description": "ZHA \u5c07\u505c\u6b62\u3001\u662f\u5426\u8981\u7e7c\u7e8c\uff1f", "title": "\u91cd\u65b0\u8a2d\u5b9a ZHA" }, + "instruct_unplug": { + "description": "\u820a\u7121\u7dda\u96fb\u5df2\u7d93\u91cd\u7f6e\uff0c\u5047\u5982\u786c\u9ad4\u4e0d\u518d\u4f7f\u7528\u3001\u53ef\u4ee5\u9032\u884c\u79fb\u9664\u3002", + "title": "\u79fb\u9664\u820a\u7121\u7dda\u96fb" + }, + "intent_migrate": { + "description": "\u820a\u7121\u7dda\u96fb\u5c07\u6703\u9032\u884c\u91cd\u7f6e\u3002\u5047\u5982\u4f7f\u7528\u7684\u9069\u914d\u5668\u70ba\u985e\u4f3c\u65bc HUSBZB-1 \u7684 Z-Wave \u8207 Zigbee \u8907\u5408\u88dd\u7f6e\uff0c\u5c07\u50c5\u6703\u91cd\u7f6e Zigbee \u90e8\u5206\u3002\n\n\u662f\u5426\u8981\u7e7c\u7e8c\uff1f", + "title": "\u9077\u79fb\u81f3\u65b0\u7121\u7dda\u96fb" + }, "manual_pick_radio_type": { "data": { "radio_type": "\u7121\u7dda\u96fb\u985e\u5225" @@ -233,6 +243,14 @@ "description": "\u5099\u4efd\u4e2d\u7684 IEEE \u4f4d\u5740\u8207\u73fe\u6709\u7121\u7dda\u96fb\u4e0d\u540c\u3002\u70ba\u4e86\u78ba\u8a8d\u7db2\u8def\u6b63\u5e38\u5de5\u4f5c\uff0c\u7121\u7dda\u96fb\u7684 IEEE \u4f4d\u5740\u5fc5\u9808\u9032\u884c\u8b8a\u66f4\u3002\n\n\u6b64\u70ba\u6c38\u4e45\u6027\u64cd\u4f5c\u3002.", "title": "\u8986\u5beb\u7121\u7dda\u96fb IEEE \u4f4d\u5740" }, + "prompt_migrate_or_reconfigure": { + "description": "\u8981\u9077\u79fb\u81f3\u65b0\u7121\u7dda\u96fb\u6216\u91cd\u65b0\u8a2d\u5b9a\u76ee\u524d\u7121\u7dda\u96fb\uff1f", + "menu_options": { + "intent_migrate": "\u9077\u79fb\u81f3\u65b0\u7121\u7dda\u96fb", + "intent_reconfigure": "\u91cd\u65b0\u8a2d\u5b9a\u76ee\u524d\u7121\u7dda\u96fb" + }, + "title": "\u9077\u79fb\u6216\u91cd\u65b0\u8a2d\u5b9a" + }, "upload_manual_backup": { "data": { "uploaded_backup_file": "\u4e0a\u50b3\u6a94\u6848" diff --git a/homeassistant/components/zone/manifest.json b/homeassistant/components/zone/manifest.json index 019049a3b715e38d35ca137286be774045b92007..fe039817c64947ebb3b695bbe2c9c534bc0d38bb 100644 --- a/homeassistant/components/zone/manifest.json +++ b/homeassistant/components/zone/manifest.json @@ -4,5 +4,6 @@ "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/zone", "codeowners": ["@home-assistant/core"], - "quality_scale": "internal" + "quality_scale": "internal", + "integration_type": "system" } diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index f8828e8cdd0e289c5856bc44a7aeffe5194d2502..cab07f4287f9cb7a36514c06f147fbe4f5ff32fb 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -142,7 +142,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await client.connect() except InvalidServerVersion as err: if use_addon: - async_ensure_addon_updated(hass) + addon_manager = _get_addon_manager(hass) + addon_manager.async_schedule_update_addon(catch_error=True) else: async_create_issue( hass, @@ -205,8 +206,7 @@ async def start_client( LOGGER.info("Connection to Zwave JS Server initialized") - if client.driver is None: - raise RuntimeError("Driver not ready.") + assert client.driver await driver_events.setup(client.driver) @@ -789,17 +789,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: info = hass.data[DOMAIN][entry.entry_id] driver_events: DriverEvents = info[DATA_DRIVER_EVENTS] - tasks: list[asyncio.Task | Coroutine] = [] - for platform, task in driver_events.platform_setup_tasks.items(): - if task.done(): - tasks.append( - hass.config_entries.async_forward_entry_unload(entry, platform) - ) - else: - task.cancel() - tasks.append(task) + tasks: list[Coroutine] = [ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform, task in driver_events.platform_setup_tasks.items() + if not task.cancel() + ] - unload_ok = all(await asyncio.gather(*tasks)) + unload_ok = all(await asyncio.gather(*tasks)) if tasks else True if DATA_CLIENT_LISTEN_TASK in info: await disconnect_client(hass, entry) @@ -842,9 +838,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: """Ensure that Z-Wave JS add-on is installed and running.""" - addon_manager: AddonManager = get_addon_manager(hass) - if addon_manager.task_in_progress(): - raise ConfigEntryNotReady + addon_manager = _get_addon_manager(hass) try: addon_info = await addon_manager.async_get_addon_info() except AddonError as err: @@ -860,24 +854,24 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> s2_unauthenticated_key: str = entry.data.get(CONF_S2_UNAUTHENTICATED_KEY, "") addon_state = addon_info.state + addon_config = { + CONF_ADDON_DEVICE: usb_path, + CONF_ADDON_S0_LEGACY_KEY: s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: s2_unauthenticated_key, + } + if addon_state == AddonState.NOT_INSTALLED: addon_manager.async_schedule_install_setup_addon( - usb_path, - s0_legacy_key, - s2_access_control_key, - s2_authenticated_key, - s2_unauthenticated_key, + addon_config, catch_error=True, ) raise ConfigEntryNotReady if addon_state == AddonState.NOT_RUNNING: addon_manager.async_schedule_setup_addon( - usb_path, - s0_legacy_key, - s2_access_control_key, - s2_authenticated_key, - s2_unauthenticated_key, + addon_config, catch_error=True, ) raise ConfigEntryNotReady @@ -911,9 +905,9 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> @callback -def async_ensure_addon_updated(hass: HomeAssistant) -> None: +def _get_addon_manager(hass: HomeAssistant) -> AddonManager: """Ensure that Z-Wave JS add-on is updated and running.""" addon_manager: AddonManager = get_addon_manager(hass) if addon_manager.task_in_progress(): raise ConfigEntryNotReady - addon_manager.async_schedule_update_addon(catch_error=True) + return addon_manager diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index 610fc850e90aa7f56914dfeee0b77710552d0016..3e27235ef84823d801b429bcef3cc195d31f109e 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -5,10 +5,10 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from enum import Enum -from functools import partial +from functools import partial, wraps from typing import Any, TypeVar -from typing_extensions import ParamSpec +from typing_extensions import Concatenate, ParamSpec from homeassistant.components.hassio import ( async_create_backup, @@ -28,17 +28,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.singleton import singleton -from .const import ( - ADDON_SLUG, - CONF_ADDON_DEVICE, - CONF_ADDON_S0_LEGACY_KEY, - CONF_ADDON_S2_ACCESS_CONTROL_KEY, - CONF_ADDON_S2_AUTHENTICATED_KEY, - CONF_ADDON_S2_UNAUTHENTICATED_KEY, - DOMAIN, - LOGGER, -) +from .const import ADDON_SLUG, DOMAIN, LOGGER +_AddonManagerT = TypeVar("_AddonManagerT", bound="AddonManager") _R = TypeVar("_R") _P = ParamSpec("_P") @@ -49,25 +41,33 @@ DATA_ADDON_MANAGER = f"{DOMAIN}_addon_manager" @callback def get_addon_manager(hass: HomeAssistant) -> AddonManager: """Get the add-on manager.""" - return AddonManager(hass) + return AddonManager(hass, "Z-Wave JS", ADDON_SLUG) def api_error( error_message: str, -) -> Callable[[Callable[_P, Awaitable[_R]]], Callable[_P, Coroutine[Any, Any, _R]]]: +) -> Callable[ + [Callable[Concatenate[_AddonManagerT, _P], Awaitable[_R]]], + Callable[Concatenate[_AddonManagerT, _P], Coroutine[Any, Any, _R]], +]: """Handle HassioAPIError and raise a specific AddonError.""" def handle_hassio_api_error( - func: Callable[_P, Awaitable[_R]] - ) -> Callable[_P, Coroutine[Any, Any, _R]]: + func: Callable[Concatenate[_AddonManagerT, _P], Awaitable[_R]] + ) -> Callable[Concatenate[_AddonManagerT, _P], Coroutine[Any, Any, _R]]: """Handle a HassioAPIError.""" - async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: + @wraps(func) + async def wrapper( + self: _AddonManagerT, *args: _P.args, **kwargs: _P.kwargs + ) -> _R: """Wrap an add-on manager method.""" try: - return_value = await func(*args, **kwargs) + return_value = await func(self, *args, **kwargs) except HassioAPIError as err: - raise AddonError(f"{error_message}: {err}") from err + raise AddonError( + f"{error_message.format(addon_name=self.addon_name)}: {err}" + ) from err return return_value @@ -100,12 +100,14 @@ class AddonManager: """Manage the add-on. Methods may raise AddonError. - Only one instance of this class may exist + Only one instance of this class may exist per add-on to keep track of running add-on tasks. """ - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, addon_name: str, addon_slug: str) -> None: """Set up the add-on manager.""" + self.addon_name = addon_name + self.addon_slug = addon_slug self._hass = hass self._install_task: asyncio.Task | None = None self._restart_task: asyncio.Task | None = None @@ -123,21 +125,23 @@ class AddonManager: ) ) - @api_error("Failed to get Z-Wave JS add-on discovery info") + @api_error("Failed to get {addon_name} add-on discovery info") async def async_get_addon_discovery_info(self) -> dict: """Return add-on discovery info.""" - discovery_info = await async_get_addon_discovery_info(self._hass, ADDON_SLUG) + discovery_info = await async_get_addon_discovery_info( + self._hass, self.addon_slug + ) if not discovery_info: - raise AddonError("Failed to get Z-Wave JS add-on discovery info") + raise AddonError(f"Failed to get {self.addon_name} add-on discovery info") discovery_info_config: dict = discovery_info["config"] return discovery_info_config - @api_error("Failed to get the Z-Wave JS add-on info") + @api_error("Failed to get the {addon_name} add-on info") async def async_get_addon_info(self) -> AddonInfo: - """Return and cache Z-Wave JS add-on info.""" - addon_store_info = await async_get_addon_store_info(self._hass, ADDON_SLUG) + """Return and cache manager add-on info.""" + addon_store_info = await async_get_addon_store_info(self._hass, self.addon_slug) LOGGER.debug("Add-on store info: %s", addon_store_info) if not addon_store_info["installed"]: return AddonInfo( @@ -147,7 +151,7 @@ class AddonManager: version=None, ) - addon_info = await async_get_addon_info(self._hass, ADDON_SLUG) + addon_info = await async_get_addon_info(self._hass, self.addon_slug) addon_state = self.async_get_addon_state(addon_info) return AddonInfo( options=addon_info["options"], @@ -158,7 +162,7 @@ class AddonManager: @callback def async_get_addon_state(self, addon_info: dict[str, Any]) -> AddonState: - """Return the current state of the Z-Wave JS add-on.""" + """Return the current state of the managed add-on.""" addon_state = AddonState.NOT_RUNNING if addon_info["state"] == "started": @@ -170,25 +174,27 @@ class AddonManager: return addon_state - @api_error("Failed to set the Z-Wave JS add-on options") + @api_error("Failed to set the {addon_name} add-on options") async def async_set_addon_options(self, config: dict) -> None: - """Set Z-Wave JS add-on options.""" + """Set manager add-on options.""" options = {"options": config} - await async_set_addon_options(self._hass, ADDON_SLUG, options) + await async_set_addon_options(self._hass, self.addon_slug, options) - @api_error("Failed to install the Z-Wave JS add-on") + @api_error("Failed to install the {addon_name} add-on") async def async_install_addon(self) -> None: - """Install the Z-Wave JS add-on.""" - await async_install_addon(self._hass, ADDON_SLUG) + """Install the managed add-on.""" + await async_install_addon(self._hass, self.addon_slug) @callback def async_schedule_install_addon(self, catch_error: bool = False) -> asyncio.Task: - """Schedule a task that installs the Z-Wave JS add-on. + """Schedule a task that installs the managed add-on. Only schedule a new install task if the there's no running task. """ if not self._install_task or self._install_task.done(): - LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on") + LOGGER.info( + "%s add-on is not installed. Installing add-on", self.addon_name + ) self._install_task = self._async_schedule_addon_operation( self.async_install_addon, catch_error=catch_error ) @@ -197,85 +203,79 @@ class AddonManager: @callback def async_schedule_install_setup_addon( self, - usb_path: str, - s0_legacy_key: str, - s2_access_control_key: str, - s2_authenticated_key: str, - s2_unauthenticated_key: str, + addon_config: dict[str, Any], catch_error: bool = False, ) -> asyncio.Task: - """Schedule a task that installs and sets up the Z-Wave JS add-on. + """Schedule a task that installs and sets up the managed add-on. Only schedule a new install task if the there's no running task. """ if not self._install_task or self._install_task.done(): - LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on") + LOGGER.info( + "%s add-on is not installed. Installing add-on", self.addon_name + ) self._install_task = self._async_schedule_addon_operation( self.async_install_addon, partial( self.async_configure_addon, - usb_path, - s0_legacy_key, - s2_access_control_key, - s2_authenticated_key, - s2_unauthenticated_key, + addon_config, ), self.async_start_addon, catch_error=catch_error, ) return self._install_task - @api_error("Failed to uninstall the Z-Wave JS add-on") + @api_error("Failed to uninstall the {addon_name} add-on") async def async_uninstall_addon(self) -> None: - """Uninstall the Z-Wave JS add-on.""" - await async_uninstall_addon(self._hass, ADDON_SLUG) + """Uninstall the managed add-on.""" + await async_uninstall_addon(self._hass, self.addon_slug) - @api_error("Failed to update the Z-Wave JS add-on") + @api_error("Failed to update the {addon_name} add-on") async def async_update_addon(self) -> None: - """Update the Z-Wave JS add-on if needed.""" + """Update the managed add-on if needed.""" addon_info = await self.async_get_addon_info() if addon_info.state is AddonState.NOT_INSTALLED: - raise AddonError("Z-Wave JS add-on is not installed") + raise AddonError(f"{self.addon_name} add-on is not installed") if not addon_info.update_available: return await self.async_create_backup() - await async_update_addon(self._hass, ADDON_SLUG) + await async_update_addon(self._hass, self.addon_slug) @callback def async_schedule_update_addon(self, catch_error: bool = False) -> asyncio.Task: - """Schedule a task that updates and sets up the Z-Wave JS add-on. + """Schedule a task that updates and sets up the managed add-on. Only schedule a new update task if the there's no running task. """ if not self._update_task or self._update_task.done(): - LOGGER.info("Trying to update the Z-Wave JS add-on") + LOGGER.info("Trying to update the %s add-on", self.addon_name) self._update_task = self._async_schedule_addon_operation( self.async_update_addon, catch_error=catch_error, ) return self._update_task - @api_error("Failed to start the Z-Wave JS add-on") + @api_error("Failed to start the {addon_name} add-on") async def async_start_addon(self) -> None: - """Start the Z-Wave JS add-on.""" - await async_start_addon(self._hass, ADDON_SLUG) + """Start the managed add-on.""" + await async_start_addon(self._hass, self.addon_slug) - @api_error("Failed to restart the Z-Wave JS add-on") + @api_error("Failed to restart the {addon_name} add-on") async def async_restart_addon(self) -> None: - """Restart the Z-Wave JS add-on.""" - await async_restart_addon(self._hass, ADDON_SLUG) + """Restart the managed add-on.""" + await async_restart_addon(self._hass, self.addon_slug) @callback def async_schedule_start_addon(self, catch_error: bool = False) -> asyncio.Task: - """Schedule a task that starts the Z-Wave JS add-on. + """Schedule a task that starts the managed add-on. Only schedule a new start task if the there's no running task. """ if not self._start_task or self._start_task.done(): - LOGGER.info("Z-Wave JS add-on is not running. Starting add-on") + LOGGER.info("%s add-on is not running. Starting add-on", self.addon_name) self._start_task = self._async_schedule_addon_operation( self.async_start_addon, catch_error=catch_error ) @@ -283,87 +283,67 @@ class AddonManager: @callback def async_schedule_restart_addon(self, catch_error: bool = False) -> asyncio.Task: - """Schedule a task that restarts the Z-Wave JS add-on. + """Schedule a task that restarts the managed add-on. Only schedule a new restart task if the there's no running task. """ if not self._restart_task or self._restart_task.done(): - LOGGER.info("Restarting Z-Wave JS add-on") + LOGGER.info("Restarting %s add-on", self.addon_name) self._restart_task = self._async_schedule_addon_operation( self.async_restart_addon, catch_error=catch_error ) return self._restart_task - @api_error("Failed to stop the Z-Wave JS add-on") + @api_error("Failed to stop the {addon_name} add-on") async def async_stop_addon(self) -> None: - """Stop the Z-Wave JS add-on.""" - await async_stop_addon(self._hass, ADDON_SLUG) + """Stop the managed add-on.""" + await async_stop_addon(self._hass, self.addon_slug) async def async_configure_addon( self, - usb_path: str, - s0_legacy_key: str, - s2_access_control_key: str, - s2_authenticated_key: str, - s2_unauthenticated_key: str, + addon_config: dict[str, Any], ) -> None: - """Configure and start Z-Wave JS add-on.""" + """Configure and start manager add-on.""" addon_info = await self.async_get_addon_info() if addon_info.state is AddonState.NOT_INSTALLED: - raise AddonError("Z-Wave JS add-on is not installed") - - new_addon_options = { - CONF_ADDON_DEVICE: usb_path, - CONF_ADDON_S0_LEGACY_KEY: s0_legacy_key, - CONF_ADDON_S2_ACCESS_CONTROL_KEY: s2_access_control_key, - CONF_ADDON_S2_AUTHENTICATED_KEY: s2_authenticated_key, - CONF_ADDON_S2_UNAUTHENTICATED_KEY: s2_unauthenticated_key, - } + raise AddonError(f"{self.addon_name} add-on is not installed") - if new_addon_options != addon_info.options: - await self.async_set_addon_options(new_addon_options) + if addon_config != addon_info.options: + await self.async_set_addon_options(addon_config) @callback def async_schedule_setup_addon( self, - usb_path: str, - s0_legacy_key: str, - s2_access_control_key: str, - s2_authenticated_key: str, - s2_unauthenticated_key: str, + addon_config: dict[str, Any], catch_error: bool = False, ) -> asyncio.Task: - """Schedule a task that configures and starts the Z-Wave JS add-on. + """Schedule a task that configures and starts the managed add-on. - Only schedule a new setup task if the there's no running task. + Only schedule a new setup task if there's no running task. """ if not self._start_task or self._start_task.done(): - LOGGER.info("Z-Wave JS add-on is not running. Starting add-on") + LOGGER.info("%s add-on is not running. Starting add-on", self.addon_name) self._start_task = self._async_schedule_addon_operation( partial( self.async_configure_addon, - usb_path, - s0_legacy_key, - s2_access_control_key, - s2_authenticated_key, - s2_unauthenticated_key, + addon_config, ), self.async_start_addon, catch_error=catch_error, ) return self._start_task - @api_error("Failed to create a backup of the Z-Wave JS add-on.") + @api_error("Failed to create a backup of the {addon_name} add-on.") async def async_create_backup(self) -> None: - """Create a partial backup of the Z-Wave JS add-on.""" + """Create a partial backup of the managed add-on.""" addon_info = await self.async_get_addon_info() - name = f"addon_{ADDON_SLUG}_{addon_info.version}" + name = f"addon_{self.addon_slug}_{addon_info.version}" LOGGER.debug("Creating backup: %s", name) await async_create_backup( self._hass, - {"name": name, "addons": [ADDON_SLUG]}, + {"name": name, "addons": [self.addon_slug]}, partial=True, ) @@ -388,4 +368,4 @@ class AddonManager: class AddonError(HomeAssistantError): - """Represent an error with Z-Wave JS add-on.""" + """Represent an error with the managed add-on.""" diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 4a5b233a2f0311efcd034c368ddb6724dc935da7..890df4add48199c3ce8ad52bb9f7b6e66942e852 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -448,7 +448,7 @@ def async_register_api(hass: HomeAssistant) -> None: ) @websocket_api.async_response async def websocket_network_status( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get the status of the Z-Wave JS network.""" if ENTRY_ID in msg: @@ -518,7 +518,7 @@ async def websocket_network_status( async def websocket_subscribe_node_status( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Subscribe to node status update events of a Z-Wave JS node.""" @@ -559,7 +559,7 @@ async def websocket_subscribe_node_status( async def websocket_node_status( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Get the status of a Z-Wave JS node.""" @@ -577,7 +577,7 @@ async def websocket_node_status( async def websocket_node_metadata( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Get the metadata of a Z-Wave JS node.""" @@ -607,7 +607,7 @@ async def websocket_node_metadata( async def websocket_node_comments( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Get the comments of a Z-Wave JS node.""" @@ -648,7 +648,7 @@ async def websocket_node_comments( async def websocket_add_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -791,7 +791,7 @@ async def websocket_add_node( async def websocket_grant_security_classes( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -819,7 +819,7 @@ async def websocket_grant_security_classes( async def websocket_validate_dsk_and_enter_pin( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -849,7 +849,7 @@ async def websocket_validate_dsk_and_enter_pin( async def websocket_provision_smart_start_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -902,7 +902,7 @@ async def websocket_provision_smart_start_node( async def websocket_unprovision_smart_start_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -935,7 +935,7 @@ async def websocket_unprovision_smart_start_node( async def websocket_get_provisioning_entries( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -961,7 +961,7 @@ async def websocket_get_provisioning_entries( async def websocket_parse_qr_code_string( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -987,7 +987,7 @@ async def websocket_parse_qr_code_string( async def websocket_supports_feature( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1013,7 +1013,7 @@ async def websocket_supports_feature( async def websocket_stop_inclusion( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1040,7 +1040,7 @@ async def websocket_stop_inclusion( async def websocket_stop_exclusion( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1068,7 +1068,7 @@ async def websocket_stop_exclusion( async def websocket_remove_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1147,7 +1147,7 @@ async def websocket_remove_node( async def websocket_replace_failed_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Replace a failed node with a new node.""" @@ -1298,7 +1298,7 @@ async def websocket_replace_failed_node( async def websocket_remove_failed_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Remove a failed node from the Z-Wave network.""" @@ -1342,7 +1342,7 @@ async def websocket_remove_failed_node( async def websocket_begin_healing_network( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1369,7 +1369,7 @@ async def websocket_begin_healing_network( async def websocket_subscribe_heal_network_progress( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1413,7 +1413,7 @@ async def websocket_subscribe_heal_network_progress( async def websocket_stop_healing_network( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1440,7 +1440,7 @@ async def websocket_stop_healing_network( async def websocket_heal_node( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Heal a node on the Z-Wave network.""" @@ -1468,7 +1468,7 @@ async def websocket_heal_node( async def websocket_refresh_node_info( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Re-interview a node.""" @@ -1518,7 +1518,7 @@ async def websocket_refresh_node_info( async def websocket_refresh_node_values( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Refresh node values.""" @@ -1540,7 +1540,7 @@ async def websocket_refresh_node_values( async def websocket_refresh_node_cc_values( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Refresh node values for a particular CommandClass.""" @@ -1574,7 +1574,7 @@ async def websocket_refresh_node_cc_values( async def websocket_set_config_parameter( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Set a config parameter value for a Z-Wave node.""" @@ -1619,7 +1619,7 @@ async def websocket_set_config_parameter( @websocket_api.async_response @async_get_node async def websocket_get_config_parameters( - hass: HomeAssistant, connection: ActiveConnection, msg: dict, node: Node + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], node: Node ) -> None: """Get a list of configuration parameters for a Z-Wave node.""" values = node.get_configuration_values() @@ -1671,7 +1671,7 @@ def filename_is_present_if_logging_to_file(obj: dict) -> dict: async def websocket_subscribe_log_updates( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1758,7 +1758,7 @@ async def websocket_subscribe_log_updates( async def websocket_update_log_config( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1782,7 +1782,7 @@ async def websocket_update_log_config( async def websocket_get_log_config( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1809,7 +1809,7 @@ async def websocket_get_log_config( async def websocket_update_data_collection_preference( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1841,7 +1841,7 @@ async def websocket_update_data_collection_preference( async def websocket_data_collection_status( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -1868,7 +1868,7 @@ async def websocket_data_collection_status( async def websocket_abort_firmware_update( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Abort a firmware update.""" @@ -1889,7 +1889,7 @@ async def websocket_abort_firmware_update( async def websocket_is_node_firmware_update_in_progress( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Get whether firmware update is in progress for given node.""" @@ -1921,7 +1921,7 @@ def _get_firmware_update_progress_dict( async def websocket_subscribe_firmware_update_status( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Subscribe to the status of a firmware update.""" @@ -1994,7 +1994,7 @@ async def websocket_subscribe_firmware_update_status( async def websocket_get_firmware_update_capabilities( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Abort a firmware update.""" @@ -2015,7 +2015,7 @@ async def websocket_get_firmware_update_capabilities( async def websocket_is_any_ota_firmware_update_in_progress( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -2092,7 +2092,7 @@ class FirmwareUploadView(HomeAssistantView): async def websocket_check_for_config_updates( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -2121,7 +2121,7 @@ async def websocket_check_for_config_updates( async def websocket_install_config_update( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -2160,7 +2160,7 @@ def _get_controller_statistics_dict( async def websocket_subscribe_controller_statistics( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], entry: ConfigEntry, client: Client, driver: Driver, @@ -2257,7 +2257,7 @@ def _get_node_statistics_dict( async def websocket_subscribe_node_statistics( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, + msg: dict[str, Any], node: Node, ) -> None: """Subsribe to the statistics updates for a node.""" diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index e2bd69a143602d1de9d762c97eb0457728b3a409..6cbb1ea3016c1ce136632872e621d00c6515cc40 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -188,7 +188,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): ) self._set_modes_and_presets() self._attr_supported_features = 0 - if len(self._hvac_presets) > 1: + if self._current_mode and len(self._hvac_presets) > 1: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE # If any setpoint value exists, we can assume temperature # can be set @@ -428,9 +428,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - if not self._fan_mode: - return - + assert self._fan_mode is not None try: new_state = int( next( @@ -484,9 +482,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" - if self._current_mode is None: - # Thermostat(valve) has no support for setting a mode, so we make it a no-op - return + assert self._current_mode is not None if preset_mode == PRESET_NONE: # try to restore to the (translated) main hvac mode await self.async_set_hvac_mode(self.hvac_mode) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index c114662888fca2c232a506b843b7fb3579bb8171..0a084b3a3095336a3f51c8e54a10e0c33472c915 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -29,6 +29,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import disconnect_client from .addon import AddonError, AddonInfo, AddonManager, AddonState, get_addon_manager from .const import ( + ADDON_SLUG, CONF_ADDON_DEVICE, CONF_ADDON_EMULATE_HARDWARE, CONF_ADDON_LOG_LEVEL, @@ -125,15 +126,20 @@ def get_usb_ports() -> dict[str, str]: ports = list_ports.comports() port_descriptions = {} for port in ports: - usb_device = usb.usb_device_from_port(port) - dev_path = usb.get_serial_by_id(usb_device.device) + vid: str | None = None + pid: str | None = None + if port.vid is not None and port.pid is not None: + usb_device = usb.usb_device_from_port(port) + vid = usb_device.vid + pid = usb_device.pid + dev_path = usb.get_serial_by_id(port.device) human_name = usb.human_readable_device_name( dev_path, - usb_device.serial_number, - usb_device.manufacturer, - usb_device.description, - usb_device.vid, - usb_device.pid, + port.serial_number, + port.manufacturer, + port.description, + vid, + pid, ) port_descriptions[dev_path] = human_name return port_descriptions @@ -492,6 +498,9 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): if self._async_in_progress(): return self.async_abort(reason="already_in_progress") + if discovery_info.slug != ADDON_SLUG: + return self.async_abort(reason="not_zwave_js_addon") + self.ws_address = ( f"ws://{discovery_info.config['host']}:{discovery_info.config['port']}" ) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 30364d127eb745c48adcbace4eab6eaba2339007..b3f3aeaf1c04814dffee2a1a48654d1098946828 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -27,7 +27,6 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -138,8 +137,7 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - if target_value is None: - raise HomeAssistantError("Missing target value on device.") + assert target_value is not None await self.info.node.async_set_value( target_value, percent_to_zwave_position(kwargs[ATTR_POSITION]) ) @@ -147,15 +145,13 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - if target_value is None: - raise HomeAssistantError("Missing target value on device.") + assert target_value is not None await self.info.node.async_set_value(target_value, 99) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - if target_value is None: - raise HomeAssistantError("Missing target value on device.") + assert target_value is not None await self.info.node.async_set_value(target_value, 0) async def async_stop_cover(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index ef34a2f12de99f451a6acd1d511685bb9dceed7a..068be7feb0b2ae0ea1702d2b8062320c31ba777d 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -2,7 +2,6 @@ from __future__ import annotations from copy import deepcopy -from dataclasses import astuple, dataclass from typing import Any from zwave_js_server.client import Client @@ -21,27 +20,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DATA_CLIENT, DOMAIN, USER_AGENT from .helpers import ( + ZwaveValueMatcher, get_home_and_node_id_from_device_entry, get_state_key_from_unique_id, get_value_id_from_unique_id, + value_matches_matcher, ) - -@dataclass -class ZwaveValueMatcher: - """Class to allow matching a Z-Wave Value.""" - - property_: str | int | None = None - command_class: int | None = None - endpoint: int | None = None - property_key: str | int | None = None - - def __post_init__(self) -> None: - """Post initialization check.""" - if all(val is None for val in astuple(self)): - raise ValueError("At least one of the fields must be set.") - - KEYS_TO_REDACT = {"homeId", "location"} VALUES_TO_REDACT = ( @@ -55,21 +40,7 @@ def redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueDataType: if zwave_value.get("value") in (None, ""): return zwave_value for value_to_redact in VALUES_TO_REDACT: - command_class = None - if "commandClass" in zwave_value: - command_class = CommandClass(zwave_value["commandClass"]) - zwave_value_id = ZwaveValueMatcher( - property_=zwave_value.get("property"), - command_class=command_class, - endpoint=zwave_value.get("endpoint"), - property_key=zwave_value.get("propertyKey"), - ) - if all( - redacted_field_val is None or redacted_field_val == zwave_value_field_val - for redacted_field_val, zwave_value_field_val in zip( - astuple(value_to_redact), astuple(zwave_value_id) - ) - ): + if value_matches_matcher(value_to_redact, zwave_value): redacted_value: ValueDataType = deepcopy(zwave_value) redacted_value["value"] = REDACTED return redacted_value diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 9ae1cd36d136a33d09af7091ea2bd7ec45c9a230..5b20572c2d845b25b7818eb69444cdf539358819 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -109,8 +109,6 @@ from homeassistant.const import ( PERCENTAGE, POWER_BTU_PER_HOUR, POWER_WATT, - PRECIPITATION_INCHES_PER_HOUR, - PRECIPITATION_MILLIMETERS_PER_HOUR, PRESSURE_INHG, PRESSURE_MMHG, PRESSURE_PSI, @@ -127,6 +125,7 @@ from homeassistant.const import ( VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, VOLUME_GALLONS, VOLUME_LITERS, + UnitOfVolumetricFlux, ) from .const import ( @@ -201,14 +200,14 @@ MULTILEVEL_SENSOR_UNIT_MAP: dict[str, set[MultilevelSensorScaleType]] = { VOLUME_GALLONS: UNIT_GALLONS, FREQUENCY_HERTZ: UNIT_HERTZ, PRESSURE_INHG: UNIT_INCHES_OF_MERCURY, - PRECIPITATION_INCHES_PER_HOUR: UNIT_INCHES_PER_HOUR, + UnitOfVolumetricFlux.INCHES_PER_HOUR: UNIT_INCHES_PER_HOUR, MASS_KILOGRAMS: UNIT_KILOGRAM, FREQUENCY_KILOHERTZ: UNIT_KILOHERTZ, VOLUME_LITERS: UNIT_LITER, LIGHT_LUX: UNIT_LUX, LENGTH_METERS: UNIT_METER, ELECTRIC_CURRENT_MILLIAMPERE: UNIT_MILLIAMPERE, - PRECIPITATION_MILLIMETERS_PER_HOUR: UNIT_MILLIMETER_HOUR, + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR: UNIT_MILLIMETER_HOUR, ELECTRIC_POTENTIAL_MILLIVOLT: UNIT_MILLIVOLT, SPEED_MILES_PER_HOUR: UNIT_MPH, SPEED_METERS_PER_SECOND: UNIT_M_S, diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 6949f3654a58fd3e9b10dbdf0e834c1e5a864f7f..792bd4fc1b164b9b24be595b8b650e8c4d75d0df 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -2,18 +2,19 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import astuple, dataclass import logging from typing import Any, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ConfigurationValueType +from zwave_js_server.const import CommandClass, ConfigurationValueType from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( ConfigurationValue, Value as ZwaveValue, + ValueDataType, get_value_id_str, ) @@ -55,6 +56,42 @@ class ZwaveValueID: property_key: str | int | None = None +@dataclass +class ZwaveValueMatcher: + """Class to allow matching a Z-Wave Value.""" + + property_: str | int | None = None + command_class: int | None = None + endpoint: int | None = None + property_key: str | int | None = None + + def __post_init__(self) -> None: + """Post initialization check.""" + if all(val is None for val in astuple(self)): + raise ValueError("At least one of the fields must be set.") + + +def value_matches_matcher( + matcher: ZwaveValueMatcher, value_data: ValueDataType +) -> bool: + """Return whether value matches matcher.""" + command_class = None + if "commandClass" in value_data: + command_class = CommandClass(value_data["commandClass"]) + zwave_value_id = ZwaveValueMatcher( + property_=value_data.get("property"), + command_class=command_class, + endpoint=value_data.get("endpoint"), + property_key=value_data.get("propertyKey"), + ) + return all( + redacted_field_val is None or redacted_field_val == zwave_value_field_val + for redacted_field_val, zwave_value_field_val in zip( + astuple(matcher), astuple(zwave_value_id) + ) + ) + + @callback def get_value_id_from_unique_id(unique_id: str) -> str | None: """ diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 5b085ab0bb375e4097905eadbf927b14e7db4802..38c1e8b181ff090fe2992a0a42f87834cd074d5b 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -21,5 +21,6 @@ } ], "zeroconf": ["_zwave-js-server._tcp.local."], - "loggers": ["zwave_js_server"] + "loggers": ["zwave_js_server"], + "integration_type": "hub" } diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index f898170e3082cf1c0fdbb29dc0b75cc54e94dd19..7f7f5d65bb83ab8b6b9a557dca2ea54ee81175dd 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -113,10 +113,7 @@ class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity): super().__init__(config_entry, driver, info) max_value = cast(int, self.info.primary_value.metadata.max) min_value = cast(int, self.info.primary_value.metadata.min) - self.correction_factor = max_value - min_value - # Fallback in case we can't properly calculate correction factor - if self.correction_factor == 0: - self.correction_factor = 1 + self.correction_factor = (max_value - min_value) or 1 # Entity class attributes self._attr_native_min_value = 0 diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 0360b96817348a3868a4a19594e99ddd88bf80bd..adb5820657f2a1f4820573d04c7746f7b3525ad0 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -11,7 +11,6 @@ from zwave_js_server.model.driver import Driver from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -173,7 +172,6 @@ class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - if (target_value := self._target_value) is None: - raise HomeAssistantError("Missing target value on device.") + assert self._target_value is not None key = next(key for key, val in self._lookup_map.items() if val == option) - await self.info.node.async_set_value(target_value, int(key)) + await self.info.node.async_set_value(self._target_value, int(key)) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 63a9071ffb6da25c0eafffba7a6606a295c471e6..2dfeaaa4a8d60125bd087113e31d929884964a70 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -88,13 +88,14 @@ def raise_exceptions_from_results( if errors := [ tup for tup in zip(zwave_objects, results) if isinstance(tup[1], Exception) ]: - lines = ( - f"{len(errors)} error(s):", + lines = [ *( f"{zwave_object} - {error.__class__.__name__}: {error.args[0]}" for zwave_object, error in errors - ), - ) + ) + ] + if len(lines) > 1: + lines.insert(0, f"{len(errors)} error(s):") raise HomeAssistantError("\n".join(lines)) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index 687d486888cedf6b7440ddafd018f828d57631d4..de9d4842ff7dbbab4052f643e8f46ae1ca6d2203 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -98,10 +98,12 @@ refresh_value: description: Force update value(s) for a Z-Wave entity fields: entity_id: - name: Entity - description: Entity whose value(s) should be refreshed + name: Entities + description: Entities to refresh values for. required: true - example: sensor.family_room_motion + example: | + - sensor.family_room_motion + - switch.kitchen selector: entity: integration: zwave_js diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 19587cf0c0fbacf8f71f1682a7dcabcd709b2f4a..7446edb0c5d99d7ae46c0a53857459e4bb9d7d13 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -58,7 +58,8 @@ "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "discovery_requires_supervisor": "Discovery requires the supervisor.", - "not_zwave_device": "Discovered device is not a Z-Wave device." + "not_zwave_device": "Discovered device is not a Z-Wave device.", + "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave JS add-on." }, "progress": { "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", diff --git a/homeassistant/components/zwave_js/translations/bg.json b/homeassistant/components/zwave_js/translations/bg.json index 7bf8bfcc76474c4eb8adc693412c48f5737af4e1..0ed3ce16d2f1c2992d03fba8f1cba20c59eb3be8 100644 --- a/homeassistant/components/zwave_js/translations/bg.json +++ b/homeassistant/components/zwave_js/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "not_zwave_js_addon": "\u041e\u0442\u043a\u0440\u0438\u0442\u0430\u0442\u0430 \u0434\u043e\u0431\u0430\u0432\u043a\u0430 \u043d\u0435 \u0435 \u043e\u0444\u0438\u0446\u0438\u0430\u043b\u043d\u0430\u0442\u0430 \u0434\u043e\u0431\u0430\u0432\u043a\u0430 \u043d\u0430 Z-Wave JS." + }, "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/zwave_js/translations/ca.json b/homeassistant/components/zwave_js/translations/ca.json index 455fd8f9127f099a636833f524982e681519e1c0..fb478f3ad5ee7becbe55206ee921cc655a792d65 100644 --- a/homeassistant/components/zwave_js/translations/ca.json +++ b/homeassistant/components/zwave_js/translations/ca.json @@ -10,7 +10,8 @@ "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", "cannot_connect": "Ha fallat la connexi\u00f3", "discovery_requires_supervisor": "El descobriment requereix el supervisor.", - "not_zwave_device": "El dispositiu descobert no \u00e9s un dispositiu Z-Wave." + "not_zwave_device": "El dispositiu descobert no \u00e9s un dispositiu Z-Wave.", + "not_zwave_js_addon": "El complement descobert no \u00e9s el complement oficial Z-Wave JS." }, "error": { "addon_start_failed": "No s'ha pogut iniciar el complement Z-Wave JS. Comprova la configuraci\u00f3.", diff --git a/homeassistant/components/zwave_js/translations/de.json b/homeassistant/components/zwave_js/translations/de.json index e200e086444c5a7c1986c5252663110ec4bb2ca7..fb3c7e1a69d9f47145c6ff6e742d6c5f4f0667c9 100644 --- a/homeassistant/components/zwave_js/translations/de.json +++ b/homeassistant/components/zwave_js/translations/de.json @@ -10,7 +10,8 @@ "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "cannot_connect": "Verbindung fehlgeschlagen", "discovery_requires_supervisor": "Discovery erfordert den Supervisor.", - "not_zwave_device": "Das erkannte Ger\u00e4t ist kein Z-Wave-Ger\u00e4t." + "not_zwave_device": "Das erkannte Ger\u00e4t ist kein Z-Wave-Ger\u00e4t.", + "not_zwave_js_addon": "Das entdeckte Add-on ist nicht das offizielle Z-Wave JS-Add-on." }, "error": { "addon_start_failed": "Fehler beim Starten des Z-Wave JS Add-Ons. \u00dcberpr\u00fcfe die Konfiguration.", diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 9224e27d90bec45df4cb7bbc2f4be4ba2c3c8a23..3d288c3ebae96707fbc4d25d629becfaf00a0ee0 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -10,7 +10,8 @@ "already_in_progress": "Configuration flow is already in progress", "cannot_connect": "Failed to connect", "discovery_requires_supervisor": "Discovery requires the supervisor.", - "not_zwave_device": "Discovered device is not a Z-Wave device." + "not_zwave_device": "Discovered device is not a Z-Wave device.", + "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave JS add-on." }, "error": { "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", diff --git a/homeassistant/components/zwave_js/translations/es.json b/homeassistant/components/zwave_js/translations/es.json index ff4d48f3f211c302961a833ff170f0c06788bdb3..73da28f6d1e1c03f0913c0ede33529897a33d4db 100644 --- a/homeassistant/components/zwave_js/translations/es.json +++ b/homeassistant/components/zwave_js/translations/es.json @@ -10,7 +10,8 @@ "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en curso", "cannot_connect": "No se pudo conectar", "discovery_requires_supervisor": "El descubrimiento requiere del supervisor.", - "not_zwave_device": "El dispositivo descubierto no es un dispositivo Z-Wave." + "not_zwave_device": "El dispositivo descubierto no es un dispositivo Z-Wave.", + "not_zwave_js_addon": "El complemento descubierto no es el complemento oficial de Z-Wave JS." }, "error": { "addon_start_failed": "No se pudo iniciar el complemento Z-Wave JS. Comprueba la configuraci\u00f3n.", diff --git a/homeassistant/components/zwave_js/translations/et.json b/homeassistant/components/zwave_js/translations/et.json index ea0686e424f9808548110bb8834752509c4947c1..df15c31b8c23d728f962edfb3077d875270f6466 100644 --- a/homeassistant/components/zwave_js/translations/et.json +++ b/homeassistant/components/zwave_js/translations/et.json @@ -10,7 +10,8 @@ "already_in_progress": "Seadistamine on juba k\u00e4imas", "cannot_connect": "\u00dchendamine nurjus", "discovery_requires_supervisor": "Avastamine n\u00f5uab supervisorit.", - "not_zwave_device": "Avastatud seade ei ole Z-Wave seade." + "not_zwave_device": "Avastatud seade ei ole Z-Wave seade.", + "not_zwave_js_addon": "Avastatud lisandmoodul ei ole ametlik Z-Wave JS-i lisandmoodul." }, "error": { "addon_start_failed": "Z-Wave JS lisandmooduli k\u00e4ivitamine nurjus. Kontrolli seadistusi.", @@ -91,6 +92,12 @@ "zwave_js.value_updated.value": "Z-Wave JS v\u00e4\u00e4rtuse muutus" } }, + "issues": { + "invalid_server_version": { + "description": "Z-Wave JS Serveri versioon, mida praegu kasutad, on selle Home Assistanti versiooni jaoks liiga vana. Selle probleemi lahendamiseks v\u00e4rskenda Z-Wave JS Server uusimale versioonile.", + "title": "Vajalik on Z-Wave JS Serveri uuem versioon" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Z-Wave JS lisandmooduli tuvastusteabe hankimine nurjus.", diff --git a/homeassistant/components/zwave_js/translations/fr.json b/homeassistant/components/zwave_js/translations/fr.json index cf7552491c732d6611f01a5accaee93fa6311638..55c613e740ac47c87522e0a3927513cd6b447422 100644 --- a/homeassistant/components/zwave_js/translations/fr.json +++ b/homeassistant/components/zwave_js/translations/fr.json @@ -10,7 +10,8 @@ "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", "cannot_connect": "\u00c9chec de connexion", "discovery_requires_supervisor": "La d\u00e9couverte n\u00e9cessite le superviseur.", - "not_zwave_device": "L'appareil d\u00e9couvert n'est pas un appareil Z-Wave." + "not_zwave_device": "L'appareil d\u00e9couvert n'est pas un appareil Z-Wave.", + "not_zwave_js_addon": "Le module compl\u00e9mentaire d\u00e9couvert n'est pas le module compl\u00e9mentaire officiel de Z-Wave JS." }, "error": { "addon_start_failed": "\u00c9chec du d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS. V\u00e9rifiez la configuration.", diff --git a/homeassistant/components/zwave_js/translations/hu.json b/homeassistant/components/zwave_js/translations/hu.json index 5bf50719b496868643b9e8d67ef4e69cf88d38e1..b1e65cc05e2b5dfd4d76001bccbcc5bd9278a8e4 100644 --- a/homeassistant/components/zwave_js/translations/hu.json +++ b/homeassistant/components/zwave_js/translations/hu.json @@ -10,7 +10,8 @@ "already_in_progress": "A be\u00e1ll\u00edt\u00e1si folyamat m\u00e1r el lett kezdve", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "discovery_requires_supervisor": "A felfedez\u00e9shez a fel\u00fcgyel\u0151re van sz\u00fcks\u00e9g.", - "not_zwave_device": "A felfedezett eszk\u00f6z nem Z-Wave eszk\u00f6z." + "not_zwave_device": "A felfedezett eszk\u00f6z nem Z-Wave eszk\u00f6z.", + "not_zwave_js_addon": "A felfedezett b\u0151v\u00edtm\u00e9ny nem a hivatalos Z-Wave JS b\u0151v\u00edtm\u00e9ny." }, "error": { "addon_start_failed": "Nem siker\u00fclt elind\u00edtani a Z-Wave JS b\u0151v\u00edtm\u00e9nyt. Ellen\u0151rizze a konfigur\u00e1ci\u00f3t.", diff --git a/homeassistant/components/zwave_js/translations/id.json b/homeassistant/components/zwave_js/translations/id.json index 1aa3c5258f501e5bb61d86e037ea435ab79ed743..c8fe3f87f669d8baf576f07ea2212a395748614c 100644 --- a/homeassistant/components/zwave_js/translations/id.json +++ b/homeassistant/components/zwave_js/translations/id.json @@ -10,7 +10,8 @@ "already_in_progress": "Alur konfigurasi sedang berlangsung", "cannot_connect": "Gagal terhubung", "discovery_requires_supervisor": "Fitur penemuan membutuhkan supervisor.", - "not_zwave_device": "Perangkat yang ditemukan bukan perangkat Z-Wave." + "not_zwave_device": "Perangkat yang ditemukan bukan perangkat Z-Wave.", + "not_zwave_js_addon": "Add-on yang ditemukan bukanlah add-on Z-Wave JS resmi." }, "error": { "addon_start_failed": "Gagal memulai add-on Z-Wave JS. Periksa konfigurasi.", diff --git a/homeassistant/components/zwave_js/translations/it.json b/homeassistant/components/zwave_js/translations/it.json index 34977cdd48c6eee794afcd4ebc0f95efff5393d8..d104f510eaf74765fc6ea322ef3854e3286a341d 100644 --- a/homeassistant/components/zwave_js/translations/it.json +++ b/homeassistant/components/zwave_js/translations/it.json @@ -10,7 +10,8 @@ "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", "cannot_connect": "Impossibile connettersi", "discovery_requires_supervisor": "Il rilevamento richiede il Supervisor.", - "not_zwave_device": "Il dispositivo rilevato non \u00e8 un dispositivo Z-Wave." + "not_zwave_device": "Il dispositivo rilevato non \u00e8 un dispositivo Z-Wave.", + "not_zwave_js_addon": "Il componente aggiuntivo rilevato non \u00e8 il componente aggiuntivo Z-Wave JS ufficiale." }, "error": { "addon_start_failed": "Impossibile avviare il componente aggiuntivo Z-Wave JS. Controlla la configurazione.", diff --git a/homeassistant/components/zwave_js/translations/ja.json b/homeassistant/components/zwave_js/translations/ja.json index 41568815193fe65af8a626837f895f6880cc501d..c42fff1813906eeca4f418dade4e632cc4f867ef 100644 --- a/homeassistant/components/zwave_js/translations/ja.json +++ b/homeassistant/components/zwave_js/translations/ja.json @@ -10,7 +10,8 @@ "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", "discovery_requires_supervisor": "\u691c\u51fa\u306b\u306fSupervisor\u304c\u5fc5\u8981\u3067\u3059\u3002", - "not_zwave_device": "\u767a\u898b\u3055\u308c\u305f\u30c7\u30d0\u30a4\u30b9\u306f\u3001Z-Wave\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002" + "not_zwave_device": "\u767a\u898b\u3055\u308c\u305f\u30c7\u30d0\u30a4\u30b9\u306f\u3001Z-Wave\u30c7\u30d0\u30a4\u30b9\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002", + "not_zwave_js_addon": "\u767a\u898b\u3055\u308c\u305f\u30a2\u30c9\u30aa\u30f3\u306f\u3001Z-Wave JS\u306e\u516c\u5f0f\u30a2\u30c9\u30aa\u30f3\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002" }, "error": { "addon_start_failed": "Z-Wave JS \u30a2\u30c9\u30aa\u30f3\u306e\u8d77\u52d5\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u8a2d\u5b9a\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", diff --git a/homeassistant/components/zwave_js/translations/nb.json b/homeassistant/components/zwave_js/translations/nb.json new file mode 100644 index 0000000000000000000000000000000000000000..42a62fb5164006152197f8fcff5fcb1863167535 --- /dev/null +++ b/homeassistant/components/zwave_js/translations/nb.json @@ -0,0 +1,12 @@ +{ + "config": { + "error": { + "unknown": "Uventet feil" + } + }, + "options": { + "error": { + "unknown": "Uventet feil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/nl.json b/homeassistant/components/zwave_js/translations/nl.json index f87b7a701e30b61dd08d28292ef0894d84fff091..f57791b23338c077f7b05750f9669e69bd1518ad 100644 --- a/homeassistant/components/zwave_js/translations/nl.json +++ b/homeassistant/components/zwave_js/translations/nl.json @@ -91,6 +91,11 @@ "zwave_js.value_updated.value": "Waardeverandering op een Z-Wave JS-waarde" } }, + "issues": { + "invalid_server_version": { + "title": "Nieuwere versie van Z-Wave JS-server vereist" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Ophalen van ontdekkingsinformatie voor Z-Wave JS-add-on is mislukt.", diff --git a/homeassistant/components/zwave_js/translations/no.json b/homeassistant/components/zwave_js/translations/no.json index 6e9ae85cd747644dfbfceb5496a457763892af89..7c3cec3f6f997c7360595fd8be67c799a6ae3a24 100644 --- a/homeassistant/components/zwave_js/translations/no.json +++ b/homeassistant/components/zwave_js/translations/no.json @@ -10,7 +10,8 @@ "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", "cannot_connect": "Tilkobling mislyktes", "discovery_requires_supervisor": "Oppdagelsen krever veilederen.", - "not_zwave_device": "Oppdaget enhet er ikke en Z-Wave-enhet." + "not_zwave_device": "Oppdaget enhet er ikke en Z-Wave-enhet.", + "not_zwave_js_addon": "Oppdaget tillegg er ikke det offisielle Z-Wave JS tillegget." }, "error": { "addon_start_failed": "Kunne ikke starte Z-Wave JS-tillegg. Sjekk konfigurasjonen.", diff --git a/homeassistant/components/zwave_js/translations/pl.json b/homeassistant/components/zwave_js/translations/pl.json index a4d5519491b3d38af3beb9fec48bf8da51f6f9ac..2028a8a122980f8bf3f123e6cb681881a5791893 100644 --- a/homeassistant/components/zwave_js/translations/pl.json +++ b/homeassistant/components/zwave_js/translations/pl.json @@ -10,7 +10,8 @@ "already_in_progress": "Konfiguracja jest ju\u017c w toku", "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "discovery_requires_supervisor": "Wykrywanie wymaga Supervisora.", - "not_zwave_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Z-Wave." + "not_zwave_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Z-Wave.", + "not_zwave_js_addon": "Wykryty dodatek nie jest oficjalnym dodatkiem Z-Wave JS." }, "error": { "addon_start_failed": "Nie uda\u0142o si\u0119 uruchomi\u0107 dodatku Z-Wave JS. Sprawd\u017a konfiguracj\u0119", diff --git a/homeassistant/components/zwave_js/translations/pt-BR.json b/homeassistant/components/zwave_js/translations/pt-BR.json index 83b6bed636573ee2fe476c9e421acb1d819c5b47..dd26153495cf837c727cf82a68aa083926f2ac91 100644 --- a/homeassistant/components/zwave_js/translations/pt-BR.json +++ b/homeassistant/components/zwave_js/translations/pt-BR.json @@ -10,7 +10,8 @@ "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", "cannot_connect": "Falha ao conectar", "discovery_requires_supervisor": "A descoberta requer o supervisor.", - "not_zwave_device": "O dispositivo descoberto n\u00e3o \u00e9 um dispositivo Z-Wave." + "not_zwave_device": "O dispositivo descoberto n\u00e3o \u00e9 um dispositivo Z-Wave.", + "not_zwave_js_addon": "O complemento descoberto n\u00e3o \u00e9 o complemento oficial do Z-Wave JS." }, "error": { "addon_start_failed": "Falha ao iniciar o add-on Z-Wave JS. Verifique a configura\u00e7\u00e3o.", diff --git a/homeassistant/components/zwave_js/translations/ru.json b/homeassistant/components/zwave_js/translations/ru.json index bbf816046dfdbd73b0a4eb391f87d6dd60decadd..3ffe43abb6fc45a1ef625b312cc0d12a8aeed160 100644 --- a/homeassistant/components/zwave_js/translations/ru.json +++ b/homeassistant/components/zwave_js/translations/ru.json @@ -10,7 +10,8 @@ "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "discovery_requires_supervisor": "\u0414\u043b\u044f \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f Supervisor.", - "not_zwave_device": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Z-Wave." + "not_zwave_device": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Z-Wave.", + "not_zwave_js_addon": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u043d\u0435\u043e\u0444\u0438\u0446\u0438\u0430\u043b\u044c\u043d\u043e\u0435 \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS." }, "error": { "addon_start_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 Z-Wave JS. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e.", diff --git a/homeassistant/components/zwave_js/translations/sv.json b/homeassistant/components/zwave_js/translations/sv.json index b619c54026b41f08641b9948dc0adcce5a93fd62..448069933d95e803e56d5501f8ae7c936b5fa2d5 100644 --- a/homeassistant/components/zwave_js/translations/sv.json +++ b/homeassistant/components/zwave_js/translations/sv.json @@ -91,6 +91,12 @@ "zwave_js.value_updated.value": "V\u00e4rdef\u00f6r\u00e4ndring p\u00e5 ett Z-Wave JS-v\u00e4rde" } }, + "issues": { + "invalid_server_version": { + "description": "Den version av Z-Wave JS Server du f\u00f6r n\u00e4rvarande k\u00f6r \u00e4r f\u00f6r gammal f\u00f6r den h\u00e4r versionen av Home Assistant. Uppdatera Z-Wave JS Server till den senaste versionen f\u00f6r att \u00e5tg\u00e4rda problemet.", + "title": "Nyare version av Z-Wave JS Server beh\u00f6vs" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Det gick inte att h\u00e4mta Z-Wave JS-till\u00e4ggsuppt\u00e4cktsinformation.", diff --git a/homeassistant/components/zwave_js/translations/tr.json b/homeassistant/components/zwave_js/translations/tr.json index 21d8f03bec60d6da2bc3cbf3ab77e1539712ace6..ed45816c2dd9b956fcf38ac996dd8a3531a98965 100644 --- a/homeassistant/components/zwave_js/translations/tr.json +++ b/homeassistant/components/zwave_js/translations/tr.json @@ -10,7 +10,8 @@ "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", "cannot_connect": "Ba\u011flanma hatas\u0131", "discovery_requires_supervisor": "Tarama, s\u00fcperviz\u00f6r\u00fc gerektirir.", - "not_zwave_device": "Bulunan cihaz bir Z-Wave cihaz\u0131 de\u011fil." + "not_zwave_device": "Bulunan cihaz bir Z-Wave cihaz\u0131 de\u011fil.", + "not_zwave_js_addon": "Ke\u015ffedilen eklenti, resmi Z-Wave JS eklentisi de\u011fildir." }, "error": { "addon_start_failed": "Z-Wave JS eklentisi ba\u015flat\u0131lamad\u0131. Yap\u0131land\u0131rmay\u0131 kontrol edin.", @@ -91,6 +92,12 @@ "zwave_js.value_updated.value": "Z-Wave JS De\u011ferinde de\u011fer de\u011fi\u015fikli\u011fi" } }, + "issues": { + "invalid_server_version": { + "description": "\u015eu anda \u00e7al\u0131\u015ft\u0131rd\u0131\u011f\u0131n\u0131z Z-Wave JS Server s\u00fcr\u00fcm\u00fc, Home Assistant'\u0131n bu s\u00fcr\u00fcm\u00fc i\u00e7in \u00e7ok eski. Bu sorunu gidermek i\u00e7in l\u00fctfen Z-Wave JS Sunucusunu en son s\u00fcr\u00fcme g\u00fcncelleyin.", + "title": "Z-Wave JS Sunucusunun daha yeni s\u00fcr\u00fcm\u00fc gerekli" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Z-Wave JS eklenti ke\u015fif bilgileri al\u0131namad\u0131.", diff --git a/homeassistant/components/zwave_js/translations/zh-Hant.json b/homeassistant/components/zwave_js/translations/zh-Hant.json index 3c5f898324a452048d771fedbf41d6a24d4841ae..c970bae125edbc703e9b1e44f1ff9ac8fbfdf6c1 100644 --- a/homeassistant/components/zwave_js/translations/zh-Hant.json +++ b/homeassistant/components/zwave_js/translations/zh-Hant.json @@ -10,7 +10,8 @@ "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", "cannot_connect": "\u9023\u7dda\u5931\u6557", "discovery_requires_supervisor": "\u641c\u7d22\u529f\u80fd\u9700\u8981 Supervisor \u6b0a\u9650\u3002", - "not_zwave_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e Z-Wave \u88dd\u7f6e" + "not_zwave_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e Z-Wave \u88dd\u7f6e", + "not_zwave_js_addon": "\u767c\u73fe\u4e4b\u9644\u52a0\u5143\u4ef6\u4e26\u975e\u5b98\u65b9 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u3002" }, "error": { "addon_start_failed": "Z-Wave JS \u9644\u52a0\u5143\u4ef6\u555f\u52d5\u5931\u6557\uff0c\u8acb\u6aa2\u67e5\u8a2d\u5b9a\u3002", diff --git a/homeassistant/config.py b/homeassistant/config.py index 91f94bbbf40a869bf4dcab6e1d1593871f64871d..e56dff4e491696e6dfc649897c3c1fcd590de9c5 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -44,9 +44,7 @@ from .const import ( CONF_TIME_ZONE, CONF_TYPE, CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_IMPERIAL, LEGACY_CONF_WHITELIST_EXTERNAL_DIRS, - TEMP_CELSIUS, __version__, ) from .core import DOMAIN as CONF_CORE, ConfigSource, HomeAssistant, callback @@ -61,7 +59,7 @@ from .helpers.typing import ConfigType from .loader import Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env -from .util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from .util.unit_system import get_unit_system, validate_unit_system from .util.yaml import SECRET_YAML, Secrets, load_yaml _LOGGER = logging.getLogger(__name__) @@ -89,6 +87,10 @@ DEFAULT_CONFIG = f""" # Loads default set of integrations. Do not remove. default_config: +# Load frontend themes from the themes folder +frontend: + themes: !include_dir_merge_named themes + # Text to speech tts: - platform: google_translate @@ -204,8 +206,8 @@ CORE_CONFIG_SCHEMA = vol.All( CONF_LATITUDE: cv.latitude, CONF_LONGITUDE: cv.longitude, CONF_ELEVATION: vol.Coerce(int), - vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, - CONF_UNIT_SYSTEM: cv.unit_system, + vol.Remove(CONF_TEMPERATURE_UNIT): cv.temperature_unit, + CONF_UNIT_SYSTEM: validate_unit_system, CONF_TIME_ZONE: cv.time_zone, vol.Optional(CONF_INTERNAL_URL): cv.url, vol.Optional(CONF_EXTERNAL_URL): cv.url, @@ -304,26 +306,26 @@ def _write_default_config(config_dir: str) -> bool: # Writing files with YAML does not create the most human readable results # So we're hard coding a YAML template. try: - with open(config_path, "wt", encoding="utf8") as config_file: + with open(config_path, "w", encoding="utf8") as config_file: config_file.write(DEFAULT_CONFIG) if not os.path.isfile(secret_path): - with open(secret_path, "wt", encoding="utf8") as secret_file: + with open(secret_path, "w", encoding="utf8") as secret_file: secret_file.write(DEFAULT_SECRETS) - with open(version_path, "wt", encoding="utf8") as version_file: + with open(version_path, "w", encoding="utf8") as version_file: version_file.write(__version__) if not os.path.isfile(automation_yaml_path): - with open(automation_yaml_path, "wt", encoding="utf8") as automation_file: + with open(automation_yaml_path, "w", encoding="utf8") as automation_file: automation_file.write("[]") if not os.path.isfile(script_yaml_path): - with open(script_yaml_path, "wt", encoding="utf8"): + with open(script_yaml_path, "w", encoding="utf8"): pass if not os.path.isfile(scene_yaml_path): - with open(scene_yaml_path, "wt", encoding="utf8"): + with open(scene_yaml_path, "w", encoding="utf8"): pass return True @@ -421,7 +423,7 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: _LOGGER.info("Migrating google tts to google_translate tts") config_raw = config_raw.replace(TTS_PRE_92, TTS_92) try: - with open(config_path, "wt", encoding="utf-8") as config_file: + with open(config_path, "w", encoding="utf-8") as config_file: config_file.write(config_raw) except OSError: _LOGGER.exception("Migrating to google_translate tts failed") @@ -433,7 +435,7 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: if os.path.isdir(lib_path): shutil.rmtree(lib_path) - with open(version_path, "wt", encoding="utf8") as outp: + with open(version_path, "w", encoding="utf8") as outp: outp.write(__version__) @@ -603,22 +605,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non hass.data[DATA_CUSTOMIZE] = EntityValues(cust_exact, cust_domain, cust_glob) if CONF_UNIT_SYSTEM in config: - if config[CONF_UNIT_SYSTEM] == CONF_UNIT_SYSTEM_IMPERIAL: - hac.units = IMPERIAL_SYSTEM - else: - hac.units = METRIC_SYSTEM - elif CONF_TEMPERATURE_UNIT in config: - unit = config[CONF_TEMPERATURE_UNIT] - hac.units = METRIC_SYSTEM if unit == TEMP_CELSIUS else IMPERIAL_SYSTEM - _LOGGER.warning( - "Found deprecated temperature unit in core " - "configuration expected unit system. Replace '%s: %s' " - "with '%s: %s'", - CONF_TEMPERATURE_UNIT, - unit, - CONF_UNIT_SYSTEM, - hac.units.name, - ) + hac.units = get_unit_system(config[CONF_UNIT_SYSTEM]) def _log_pkg_error(package: str, component: str, config: dict, message: str) -> None: diff --git a/homeassistant/const.py b/homeassistant/const.py index da1e691903fe119ca830f69d5589eb51211ac0b8..5ba07ebf8fdb769ddc7a5eb4395c29369b417061 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,8 +7,8 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2022 -MINOR_VERSION: Final = 10 -PATCH_VERSION: Final = "5" +MINOR_VERSION: Final = 11 +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) @@ -396,7 +396,9 @@ ATTR_ICON: Final = "icon" ATTR_UNIT_OF_MEASUREMENT: Final = "unit_of_measurement" CONF_UNIT_SYSTEM_METRIC: Final = "metric" +"""Deprecated: please use a local constant.""" CONF_UNIT_SYSTEM_IMPERIAL: Final = "imperial" +"""Deprecated: please use a local constant.""" # Electrical attributes ATTR_VOLTAGE: Final = "voltage" @@ -476,18 +478,43 @@ ATTR_PERSONS: Final = "persons" # Apparent power units POWER_VOLT_AMPERE: Final = "VA" + # Power units +class UnitOfPower(StrEnum): + """Power units.""" + + WATT = "W" + KILO_WATT = "kW" + BTU_PER_HOUR = "BTU/h" + + POWER_WATT: Final = "W" +"""Deprecated: please use UnitOfPower.WATT.""" POWER_KILO_WATT: Final = "kW" +"""Deprecated: please use UnitOfPower.KILO_WATT.""" POWER_BTU_PER_HOUR: Final = "BTU/h" +"""Deprecated: please use UnitOfPower.BTU_PER_HOUR.""" # Reactive power units POWER_VOLT_AMPERE_REACTIVE: Final = "var" + # Energy units -ENERGY_WATT_HOUR: Final = "Wh" +class UnitOfEnergy(StrEnum): + """Energy units.""" + + GIGA_JOULE = "GJ" + KILO_WATT_HOUR = "kWh" + MEGA_WATT_HOUR = "MWh" + WATT_HOUR = "Wh" + + ENERGY_KILO_WATT_HOUR: Final = "kWh" +"""Deprecated: please use UnitOfEnergy.KILO_WATT_HOUR.""" ENERGY_MEGA_WATT_HOUR: Final = "MWh" +"""Deprecated: please use UnitOfEnergy.MEGA_WATT_HOUR.""" +ENERGY_WATT_HOUR: Final = "Wh" +"""Deprecated: please use UnitOfEnergy.WATT_HOUR.""" # Electric_current units ELECTRIC_CURRENT_MILLIAMPERE: Final = "mA" @@ -505,10 +532,22 @@ CURRENCY_EURO: Final = "€" CURRENCY_DOLLAR: Final = "$" CURRENCY_CENT: Final = "¢" + # Temperature units +class UnitOfTemperature(StrEnum): + """Temperature units.""" + + CELSIUS = "°C" + FAHRENHEIT = "°F" + KELVIN = "K" + + TEMP_CELSIUS: Final = "°C" +"""Deprecated: please use UnitOfTemperature.CELSIUS""" TEMP_FAHRENHEIT: Final = "°F" +"""Deprecated: please use UnitOfTemperature.FAHRENHEIT""" TEMP_KELVIN: Final = "K" +"""Deprecated: please use UnitOfTemperature.KELVIN""" # Time units TIME_MICROSECONDS: Final = "μs" @@ -521,16 +560,37 @@ TIME_WEEKS: Final = "w" TIME_MONTHS: Final = "m" TIME_YEARS: Final = "y" + # Length units +class UnitOfLength(StrEnum): + """Length units.""" + + MILLIMETERS = "mm" + CENTIMETERS = "cm" + METERS = "m" + KILOMETERS = "km" + INCHES = "in" + FEET = "ft" + YARDS = "yd" + MILES = "mi" + + LENGTH_MILLIMETERS: Final = "mm" +"""Deprecated: please use UnitOfLength.MILLIMETERS.""" LENGTH_CENTIMETERS: Final = "cm" +"""Deprecated: please use UnitOfLength.CENTIMETERS.""" LENGTH_METERS: Final = "m" +"""Deprecated: please use UnitOfLength.METERS.""" LENGTH_KILOMETERS: Final = "km" - +"""Deprecated: please use UnitOfLength.KILOMETERS.""" LENGTH_INCHES: Final = "in" +"""Deprecated: please use UnitOfLength.INCHES.""" LENGTH_FEET: Final = "ft" +"""Deprecated: please use UnitOfLength.FEET.""" LENGTH_YARD: Final = "yd" +"""Deprecated: please use UnitOfLength.YARDS.""" LENGTH_MILES: Final = "mi" +"""Deprecated: please use UnitOfLength.MILES.""" # Frequency units FREQUENCY_HERTZ: Final = "Hz" @@ -538,32 +598,77 @@ FREQUENCY_KILOHERTZ: Final = "kHz" FREQUENCY_MEGAHERTZ: Final = "MHz" FREQUENCY_GIGAHERTZ: Final = "GHz" + # Pressure units +class UnitOfPressure(StrEnum): + """Pressure units.""" + + PA = "Pa" + HPA = "hPa" + KPA = "kPa" + BAR = "bar" + CBAR = "cbar" + MBAR = "mbar" + MMHG = "mmHg" + INHG = "inHg" + PSI = "psi" + + PRESSURE_PA: Final = "Pa" +"""Deprecated: please use UnitOfPressure.PA""" PRESSURE_HPA: Final = "hPa" +"""Deprecated: please use UnitOfPressure.HPA""" PRESSURE_KPA: Final = "kPa" +"""Deprecated: please use UnitOfPressure.KPA""" PRESSURE_BAR: Final = "bar" +"""Deprecated: please use UnitOfPressure.BAR""" PRESSURE_CBAR: Final = "cbar" +"""Deprecated: please use UnitOfPressure.CBAR""" PRESSURE_MBAR: Final = "mbar" +"""Deprecated: please use UnitOfPressure.MBAR""" PRESSURE_MMHG: Final = "mmHg" +"""Deprecated: please use UnitOfPressure.MMHG""" PRESSURE_INHG: Final = "inHg" +"""Deprecated: please use UnitOfPressure.INHG""" PRESSURE_PSI: Final = "psi" +"""Deprecated: please use UnitOfPressure.PSI""" # Sound pressure units SOUND_PRESSURE_DB: Final = "dB" SOUND_PRESSURE_WEIGHTED_DBA: Final = "dBa" + # Volume units +class UnitOfVolume(StrEnum): + """Volume units.""" + + CUBIC_FEET = "ft³" + CUBIC_METERS = "m³" + LITERS = "L" + MILLILITERS = "mL" + GALLONS = "gal" + """Assumed to be US gallons in conversion utilities. + + British/Imperial gallons are not yet supported""" + FLUID_OUNCES = "fl. oz." + """Assumed to be US fluid ounces in conversion utilities. + + British/Imperial fluid ounces are not yet supported""" + + VOLUME_LITERS: Final = "L" +"""Deprecated: please use UnitOfVolume.LITERS""" VOLUME_MILLILITERS: Final = "mL" +"""Deprecated: please use UnitOfVolume.MILLILITERS""" VOLUME_CUBIC_METERS: Final = "m³" +"""Deprecated: please use UnitOfVolume.CUBIC_METERS""" VOLUME_CUBIC_FEET: Final = "ft³" +"""Deprecated: please use UnitOfVolume.CUBIC_FEET""" VOLUME_GALLONS: Final = "gal" -"""US gallon (British gallon is not yet supported)""" - +"""Deprecated: please use UnitOfVolume.GALLONS""" VOLUME_FLUID_OUNCE: Final = "fl. oz." -"""US fluid ounce (British fluid ounce is not yet supported)""" +"""Deprecated: please use UnitOfVolume.FLUID_OUNCES""" # Volume Flow Rate units VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = "m³/h" @@ -572,14 +677,31 @@ VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: Final = "ft³/m" # Area units AREA_SQUARE_METERS: Final = "m²" + # Mass units +class UnitOfMass(StrEnum): + """Mass units.""" + + GRAMS = "g" + KILOGRAMS = "kg" + MILLIGRAMS = "mg" + MICROGRAMS = "µg" + OUNCES = "oz" + POUNDS = "lb" + + MASS_GRAMS: Final = "g" +"""Deprecated: please use UnitOfMass.GRAMS""" MASS_KILOGRAMS: Final = "kg" +"""Deprecated: please use UnitOfMass.KILOGRAMS""" MASS_MILLIGRAMS: Final = "mg" +"""Deprecated: please use UnitOfMass.MILLIGRAMS""" MASS_MICROGRAMS: Final = "µg" - +"""Deprecated: please use UnitOfMass.MICROGRAMS""" MASS_OUNCES: Final = "oz" +"""Deprecated: please use UnitOfMass.OUNCES""" MASS_POUNDS: Final = "lb" +"""Deprecated: please use UnitOfMass.POUNDS""" # Conductivity units CONDUCTIVITY: Final = "µS/cm" @@ -600,10 +722,38 @@ REVOLUTIONS_PER_MINUTE: Final = "rpm" IRRADIATION_WATTS_PER_SQUARE_METER: Final = "W/m²" IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = "BTU/(h×ft²)" + +class UnitOfVolumetricFlux(StrEnum): + """Volumetric flux, commonly used for precipitation intensity. + + The derivation of these units is a volume of rain amassing in a container + with constant cross section in a given time + """ + + INCHES_PER_DAY = "in/d" + """Derived from in³/(in².d)""" + + INCHES_PER_HOUR = "in/h" + """Derived from in³/(in².h)""" + + MILLIMETERS_PER_DAY = "mm/d" + """Derived from mm³/(mm².d)""" + + MILLIMETERS_PER_HOUR = "mm/h" + """Derived from mm³/(mm².h)""" + + # Precipitation units -PRECIPITATION_MILLIMETERS_PER_HOUR: Final = "mm/h" +# The derivation of these units is a volume of rain amassing in a container +# with constant cross section PRECIPITATION_INCHES: Final = "in" +PRECIPITATION_MILLIMETERS: Final = "mm" + +PRECIPITATION_MILLIMETERS_PER_HOUR: Final = "mm/h" +"""Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR""" + PRECIPITATION_INCHES_PER_HOUR: Final = "in/h" +"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR""" # Concentration units CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³" @@ -613,15 +763,38 @@ CONCENTRATION_PARTS_PER_CUBIC_METER: Final = "p/m³" CONCENTRATION_PARTS_PER_MILLION: Final = "ppm" CONCENTRATION_PARTS_PER_BILLION: Final = "ppb" + # Speed units -SPEED_MILLIMETERS_PER_DAY: Final = "mm/d" +class UnitOfSpeed(StrEnum): + """Speed units.""" + + FEET_PER_SECOND = "ft/s" + METERS_PER_SECOND = "m/s" + KILOMETERS_PER_HOUR = "km/h" + KNOTS = "kn" + MILES_PER_HOUR = "mph" + + SPEED_FEET_PER_SECOND: Final = "ft/s" -SPEED_INCHES_PER_DAY: Final = "in/d" +"""Deprecated: please use UnitOfSpeed.FEET_PER_SECOND""" SPEED_METERS_PER_SECOND: Final = "m/s" -SPEED_INCHES_PER_HOUR: Final = "in/h" +"""Deprecated: please use UnitOfSpeed.METERS_PER_SECOND""" SPEED_KILOMETERS_PER_HOUR: Final = "km/h" +"""Deprecated: please use UnitOfSpeed.KILOMETERS_PER_HOUR""" SPEED_KNOTS: Final = "kn" +"""Deprecated: please use UnitOfSpeed.KNOTS""" SPEED_MILES_PER_HOUR: Final = "mph" +"""Deprecated: please use UnitOfSpeed.MILES_PER_HOUR""" + +SPEED_MILLIMETERS_PER_DAY: Final = "mm/d" +"""Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_DAY""" + +SPEED_INCHES_PER_DAY: Final = "in/d" +"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_DAY""" + +SPEED_INCHES_PER_HOUR: Final = "in/h" +"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR""" + # Signal_strength units SIGNAL_STRENGTH_DECIBELS: Final = "dB" @@ -786,4 +959,4 @@ CAST_APP_ID_HOMEASSISTANT_LOVELACE: Final = "A078F6B0" # User used by Supervisor HASSIO_USER_NAME = "Supervisor" -SIGNAL_BOOTSTRAP_INTEGRATONS = "bootstrap_integrations" +SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations" diff --git a/homeassistant/core.py b/homeassistant/core.py index 01c75fb707e34b6ff7e1ebfac7090dd10e56b260..8f9287aedac0977836b05e0d91aaefd3c8099dcb 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -49,7 +49,6 @@ from .const import ( ATTR_FRIENDLY_NAME, ATTR_SERVICE, ATTR_SERVICE_DATA, - CONF_UNIT_SYSTEM_IMPERIAL, EVENT_CALL_SERVICE, EVENT_CORE_CONFIG_UPDATE, EVENT_HOMEASSISTANT_CLOSE, @@ -82,7 +81,13 @@ from .util.async_ import ( ) from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager -from .util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem +from .util.unit_system import ( + _CONF_UNIT_SYSTEM_IMPERIAL, + _CONF_UNIT_SYSTEM_US_CUSTOMARY, + METRIC_SYSTEM, + UnitSystem, + get_unit_system, +) # Typing imports that create a circular dependency if TYPE_CHECKING: @@ -108,6 +113,7 @@ CALLBACK_TYPE = Callable[[], None] # pylint: disable=invalid-name CORE_STORAGE_KEY = "core.config" CORE_STORAGE_VERSION = 1 +CORE_STORAGE_MINOR_VERSION = 2 DOMAIN = "homeassistant" @@ -649,7 +655,7 @@ class HomeAssistant: else: await asyncio.sleep(0) - async def _await_and_log_pending(self, pending: Iterable[Awaitable[Any]]) -> None: + async def _await_and_log_pending(self, pending: Collection[Awaitable[Any]]) -> None: """Await and log tasks that take a long time.""" wait_time = 0 while pending: @@ -1790,6 +1796,8 @@ class Config: """Initialize a new config object.""" self.hass = hass + self._store = self._ConfigStore(self.hass) + self.latitude: float = 0 self.longitude: float = 0 self.elevation: int = 0 @@ -1940,9 +1948,9 @@ class Config: if elevation is not None: self.elevation = elevation if unit_system is not None: - if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: - self.units = IMPERIAL_SYSTEM - else: + try: + self.units = get_unit_system(unit_system) + except ValueError: self.units = METRIC_SYSTEM if location_name is not None: self.location_name = location_name @@ -1958,24 +1966,12 @@ class Config: async def async_update(self, **kwargs: Any) -> None: """Update the configuration from a dictionary.""" self._update(source=ConfigSource.STORAGE, **kwargs) - await self.async_store() + await self._async_store() self.hass.bus.async_fire(EVENT_CORE_CONFIG_UPDATE, kwargs) async def async_load(self) -> None: """Load [homeassistant] core config.""" - # Circular dep - # pylint: disable=import-outside-toplevel - from .helpers.storage import Store - - store = Store[dict[str, Any]]( - self.hass, - CORE_STORAGE_VERSION, - CORE_STORAGE_KEY, - private=True, - atomic_writes=True, - ) - - if not (data := await store.async_load()): + if not (data := await self._store.async_load()): return # In 2021.9 we fixed validation to disallow a path (because that's never correct) @@ -1997,7 +1993,7 @@ class Config: latitude=data.get("latitude"), longitude=data.get("longitude"), elevation=data.get("elevation"), - unit_system=data.get("unit_system"), + unit_system=data.get("unit_system_v2"), location_name=data.get("location_name"), time_zone=data.get("time_zone"), external_url=data.get("external_url", _UNDEF), @@ -2005,17 +2001,15 @@ class Config: currency=data.get("currency"), ) - async def async_store(self) -> None: + async def _async_store(self) -> None: """Store [homeassistant] core config.""" - # Circular dep - # pylint: disable=import-outside-toplevel - from .helpers.storage import Store - data = { "latitude": self.latitude, "longitude": self.longitude, "elevation": self.elevation, - "unit_system": self.units.name, + # We don't want any integrations to use the name of the unit system + # so we are using the private attribute here + "unit_system_v2": self.units._name, # pylint: disable=protected-access "location_name": self.location_name, "time_zone": self.time_zone, "external_url": self.external_url, @@ -2023,11 +2017,47 @@ class Config: "currency": self.currency, } - store: Store[dict[str, Any]] = Store( - self.hass, - CORE_STORAGE_VERSION, - CORE_STORAGE_KEY, - private=True, - atomic_writes=True, - ) - await store.async_save(data) + await self._store.async_save(data) + + # Circular dependency prevents us from generating the class at top level + # pylint: disable-next=import-outside-toplevel + from .helpers.storage import Store + + class _ConfigStore(Store[dict[str, Any]]): + """Class to help storing Config data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize storage class.""" + super().__init__( + hass, + CORE_STORAGE_VERSION, + CORE_STORAGE_KEY, + private=True, + atomic_writes=True, + minor_version=CORE_STORAGE_MINOR_VERSION, + ) + self._original_unit_system: str | None = None # from old store 1.1 + + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict[str, Any], + ) -> dict[str, Any]: + """Migrate to the new version.""" + data = old_data + if old_major_version == 1 and old_minor_version < 2: + # In 1.2, we remove support for "imperial", replaced by "us_customary" + # Using a new key to allow rollback + self._original_unit_system = data.get("unit_system") + data["unit_system_v2"] = self._original_unit_system + if data["unit_system_v2"] == _CONF_UNIT_SYSTEM_IMPERIAL: + data["unit_system_v2"] = _CONF_UNIT_SYSTEM_US_CUSTOMARY + if old_major_version > 1: + raise NotImplementedError + return data + + async def async_save(self, data: dict[str, Any]) -> None: + if self._original_unit_system: + data["unit_system"] = self._original_unit_system + return await super().async_save(data) diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 181a4034bf0d7da045745adc0036fb33afc6f30a..c4dd22cef17846a050be20309088c1b902e0777d 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -5,6 +5,10 @@ To update, run python3 -m script.hassfest from __future__ import annotations BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ + { + "domain": "airthings_ble", + "manufacturer_id": 820, + }, { "domain": "bluemaestro", "manufacturer_id": 307, @@ -223,6 +227,10 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "local_name": "Moat_S*", "connectable": False, }, + { + "domain": "oralb", + "manufacturer_id": 220, + }, { "domain": "qingping", "local_name": "Qingping*", @@ -265,6 +273,14 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "local_name": "SensorPush*", "connectable": False, }, + { + "domain": "snooz", + "local_name": "Snooz*", + }, + { + "domain": "snooz", + "service_uuid": "729f0608-496a-47fe-a124-3a62aaa3fbc0", + }, { "domain": "switchbot", "service_data_uuid": "00000d00-0000-1000-8000-00805f9b34fb", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ba6c76d329afe8f40cb0237e4c1ce61077c9da42..772068401a54538e50707af2079243c3750b553d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -16,6 +16,7 @@ FLOWS = { "airly", "airnow", "airthings", + "airthings_ble", "airtouch4", "airvisual", "airzone", @@ -279,6 +280,7 @@ FLOWS = { "opentherm_gw", "openuv", "openweathermap", + "oralb", "overkiz", "ovo_energy", "owntracks", @@ -355,6 +357,7 @@ FLOWS = { "smarttub", "smhi", "sms", + "snooz", "solaredge", "solarlog", "solax", @@ -458,6 +461,7 @@ FLOWS = { "yeelight", "yolink", "youless", + "zamg", "zerproc", "zha", "zwave_js", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index ae1b7a76a88c87d58b8195aac2970cddf0e8db9e..4b8dee0d956ded91d34dc6331419b01bf4f51f83 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -93,6 +93,7 @@ DHCP: list[dict[str, str | bool]] = [ {"domain": "roomba", "hostname": "irobot-*", "macaddress": "501479*"}, {"domain": "roomba", "hostname": "roomba-*", "macaddress": "80A589*"}, {"domain": "roomba", "hostname": "roomba-*", "macaddress": "DCF505*"}, + {"domain": "roomba", "hostname": "roomba-*", "macaddress": "204EF6*"}, {"domain": "samsungtv", "registered_devices": True}, {"domain": "samsungtv", "hostname": "tizen*"}, {"domain": "samsungtv", "macaddress": "4844F7*"}, @@ -142,6 +143,7 @@ DHCP: list[dict[str, str | bool]] = [ {"domain": "tplink", "registered_devices": True}, {"domain": "tplink", "hostname": "es*", "macaddress": "54AF97*"}, {"domain": "tplink", "hostname": "ep*", "macaddress": "E848B8*"}, + {"domain": "tplink", "hostname": "ep*", "macaddress": "1C61B4*"}, {"domain": "tplink", "hostname": "ep*", "macaddress": "003192*"}, {"domain": "tplink", "hostname": "hs*", "macaddress": "1C3BF3*"}, {"domain": "tplink", "hostname": "hs*", "macaddress": "50C7BF*"}, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4fd58cd88f34fa5e77859d890d2d1ac0f6e011d0..08317d06a5c82b5f1d8cf107caa09c99e825fd13 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1,752 +1,941 @@ { "integration": { + "3_day_blinds": { + "name": "3 Day Blinds", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "abode": { + "name": "Abode", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Abode" + "iot_class": "cloud_push" }, "accuweather": { + "name": "AccuWeather", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "AccuWeather" + "iot_class": "cloud_polling" }, "acer_projector": { + "name": "Acer Projector", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Acer Projector" + "iot_class": "local_polling" }, "acmeda": { + "name": "Rollease Acmeda Automate", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Rollease Acmeda Automate" + "iot_class": "local_push" }, "actiontec": { + "name": "Actiontec", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Actiontec" + "iot_class": "local_polling" }, "adax": { + "name": "Adax", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Adax" + "iot_class": "local_polling" }, "adguard": { + "name": "AdGuard Home", + "integration_type": "service", "config_flow": true, - "iot_class": "local_polling", - "name": "AdGuard Home" + "iot_class": "local_polling" }, "ads": { + "name": "ADS", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "ADS" + "iot_class": "local_push" }, "advantage_air": { + "name": "Advantage Air", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Advantage Air" + "iot_class": "local_polling" }, "aemet": { + "name": "AEMET OpenData", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "AEMET OpenData" + "iot_class": "cloud_polling" }, "aftership": { + "name": "AfterShip", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "AfterShip" + "iot_class": "cloud_polling" }, "agent_dvr": { + "name": "Agent DVR", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Agent DVR" + "iot_class": "local_polling" }, "airly": { + "name": "Airly", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Airly" + "iot_class": "cloud_polling" }, "airnow": { + "name": "AirNow", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "AirNow" + "iot_class": "cloud_polling" }, "airthings": { - "config_flow": true, - "iot_class": "cloud_polling", - "name": "Airthings" + "name": "Airthings", + "integrations": { + "airthings": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Airthings" + }, + "airthings_ble": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Airthings BLE" + } + } }, "airtouch4": { + "name": "AirTouch 4", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "AirTouch 4" + "iot_class": "local_polling" }, "airvisual": { + "name": "AirVisual", + "integration_type": "device", "config_flow": true, - "iot_class": "cloud_polling", - "name": "AirVisual" + "iot_class": "cloud_polling" }, "airzone": { + "name": "Airzone", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Airzone" + "iot_class": "local_polling" }, "aladdin_connect": { + "name": "Aladdin Connect", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Aladdin Connect" + "iot_class": "cloud_polling" }, "alarmdecoder": { + "name": "AlarmDecoder", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "AlarmDecoder" + "iot_class": "local_push" }, "alert": { + "name": "Alert", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Alert" + "iot_class": "local_push" }, "almond": { + "name": "Almond", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Almond" + "iot_class": "local_polling" }, "alpha_vantage": { + "name": "Alpha Vantage", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Alpha Vantage" + "iot_class": "cloud_polling" }, "amazon": { "name": "Amazon", "integrations": { "alexa": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Amazon Alexa" }, "amazon_polly": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Amazon Polly" }, "aws": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Amazon Web Services (AWS)" }, "route53": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "AWS Route53" } } }, "amberelectric": { + "name": "Amber Electric", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Amber Electric" + "iot_class": "cloud_polling" }, "ambiclimate": { + "name": "Ambiclimate", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Ambiclimate" + "iot_class": "cloud_polling" }, "ambient_station": { + "name": "Ambient Weather Station", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Ambient Weather Station" + "iot_class": "cloud_push" }, "amcrest": { + "name": "Amcrest", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Amcrest" + "iot_class": "local_polling" + }, + "amp_motorization": { + "name": "AMP Motorization", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "ampio": { + "name": "Ampio Smart Smog System", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Ampio Smart Smog System" + "iot_class": "cloud_polling" }, "android_ip_webcam": { + "name": "Android IP Webcam", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Android IP Webcam" + "iot_class": "local_polling" }, "androidtv": { + "name": "Android TV", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Android TV" + "iot_class": "local_polling" }, "anel_pwrctrl": { + "name": "Anel NET-PwrCtrl", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Anel NET-PwrCtrl" + "iot_class": "local_polling" }, "anthemav": { + "name": "Anthem A/V Receivers", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Anthem A/V Receivers" + "iot_class": "local_push" }, "apache_kafka": { + "name": "Apache Kafka", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Apache Kafka" + "iot_class": "local_push" }, "apcupsd": { + "name": "APC UPS Daemon", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "APC UPS Daemon" + "iot_class": "local_polling" }, "apple": { "name": "Apple", "integrations": { "apple_tv": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Apple TV" }, "homekit_controller": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push" }, "homekit": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "HomeKit" }, "ibeacon": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "iBeacon Tracker" }, "icloud": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Apple iCloud" }, "itunes": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Apple iTunes" } } }, "apprise": { + "name": "Apprise", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Apprise" + "iot_class": "cloud_push" }, "aprs": { + "name": "APRS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "APRS" + "iot_class": "cloud_push" }, "aqualogic": { + "name": "AquaLogic", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "AquaLogic" + "iot_class": "local_push" }, "aquostv": { + "name": "Sharp Aquos TV", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Sharp Aquos TV" + "iot_class": "local_polling" }, "arcam_fmj": { + "name": "Arcam FMJ Receivers", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Arcam FMJ Receivers" + "iot_class": "local_polling" }, "arest": { + "name": "aREST", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "aREST" + "iot_class": "local_polling" }, "arris_tg2492lg": { + "name": "Arris TG2492LG", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Arris TG2492LG" + "iot_class": "local_polling" }, "aruba": { "name": "Aruba", "integrations": { "aruba": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Aruba" }, "cppm_tracker": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Aruba ClearPass" } } }, "arwn": { + "name": "Ambient Radio Weather Network", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ambient Radio Weather Network" + "iot_class": "local_polling" }, "aseko_pool_live": { + "name": "Aseko Pool Live", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Aseko Pool Live" + "iot_class": "cloud_polling" }, "asterisk": { "name": "Asterisk", "integrations": { "asterisk_cdr": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Asterisk Call Detail Records" }, "asterisk_mbox": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "Asterisk Voicemail" } } }, "asuswrt": { + "name": "ASUSWRT", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "ASUSWRT" + "iot_class": "local_polling" }, "atag": { + "name": "Atag", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Atag" + "iot_class": "local_polling" }, "aten_pe": { + "name": "ATEN Rack PDU", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "ATEN Rack PDU" + "iot_class": "local_polling" }, "atome": { + "name": "Atome Linky", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Atome Linky" + "iot_class": "cloud_polling" }, "august": { "name": "August Home", "integrations": { "august": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "August" }, "yalexs_ble": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Yale Access Bluetooth" } } }, + "august_ble": { + "name": "August Bluetooth", + "integration_type": "virtual", + "supported_by": "yalexs_ble" + }, "aurora": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, "aurora_abb_powerone": { + "name": "Aurora ABB PowerOne Solar PV", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Aurora ABB PowerOne Solar PV" + "iot_class": "local_polling" }, "aussie_broadband": { + "name": "Aussie Broadband", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Aussie Broadband" + "iot_class": "cloud_polling" }, "avion": { + "name": "Avi-on", + "integration_type": "hub", "config_flow": false, - "iot_class": "assumed_state", - "name": "Avi-on" + "iot_class": "assumed_state" }, "awair": { + "name": "Awair", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Awair" + "iot_class": "local_polling" }, "axis": { + "name": "Axis", + "integration_type": "device", "config_flow": true, - "iot_class": "local_push", - "name": "Axis" + "iot_class": "local_push" }, "baf": { + "name": "Big Ass Fans", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Big Ass Fans" + "iot_class": "local_push" }, "baidu": { + "name": "Baidu", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Baidu" + "iot_class": "cloud_push" }, "balboa": { + "name": "Balboa Spa Client", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Balboa Spa Client" + "iot_class": "local_push" }, "bayesian": { + "name": "Bayesian", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Bayesian" + "iot_class": "local_polling" }, "bbox": { + "name": "Bbox", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Bbox" + "iot_class": "local_polling" }, "beewi_smartclim": { + "name": "BeeWi SmartClim BLE sensor", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "BeeWi SmartClim BLE sensor" + "iot_class": "local_polling" }, "bitcoin": { + "name": "Bitcoin", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Bitcoin" + "iot_class": "cloud_polling" }, "bizkaibus": { + "name": "Bizkaibus", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Bizkaibus" + "iot_class": "cloud_polling" }, "blackbird": { + "name": "Monoprice Blackbird Matrix Switch", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Monoprice Blackbird Matrix Switch" + "iot_class": "local_polling" }, "blebox": { + "name": "BleBox devices", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "BleBox devices" + "iot_class": "local_polling" }, "blink": { + "name": "Blink", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Blink" + "iot_class": "cloud_polling" }, "blinksticklight": { + "name": "BlinkStick", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "BlinkStick" + "iot_class": "local_polling" + }, + "bliss_automation": { + "name": "Bliss Automation", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, + "bloc_blinds": { + "name": "Bloc Blinds", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "blockchain": { + "name": "Blockchain.com", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Blockchain.com" + "iot_class": "cloud_polling" }, "bloomsky": { + "name": "BloomSky", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "BloomSky" + "iot_class": "cloud_polling" }, "bluemaestro": { + "name": "BlueMaestro", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "BlueMaestro" + "iot_class": "local_push" }, "bluesound": { + "name": "Bluesound", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Bluesound" + "iot_class": "local_polling" }, "bluetooth": { + "name": "Bluetooth", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Bluetooth" + "iot_class": "local_push" }, "bluetooth_le_tracker": { + "name": "Bluetooth LE Tracker", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Bluetooth LE Tracker" + "iot_class": "local_push" }, "bluetooth_tracker": { + "name": "Bluetooth Tracker", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Bluetooth Tracker" + "iot_class": "local_polling" }, "bmw_connected_drive": { + "name": "BMW Connected Drive", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "BMW Connected Drive" + "iot_class": "cloud_polling" }, "bond": { + "name": "Bond", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Bond" + "iot_class": "local_push" }, "bosch_shc": { + "name": "Bosch SHC", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Bosch SHC" + "iot_class": "local_push" + }, + "brel_home": { + "name": "Brel Home", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "broadlink": { + "name": "Broadlink", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Broadlink" + "iot_class": "local_polling" }, "brother": { + "name": "Brother Printer", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "Brother Printer" + "iot_class": "local_polling" }, "brottsplatskartan": { + "name": "Brottsplatskartan", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Brottsplatskartan" + "iot_class": "cloud_polling" }, "browser": { + "name": "Browser", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Browser" + "iot_class": "local_push" }, "brunt": { + "name": "Brunt Blind Engine", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Brunt Blind Engine" + "iot_class": "cloud_polling" }, "bsblan": { + "name": "BSB-Lan", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "BSB-Lan" + "iot_class": "local_polling" + }, + "bswitch": { + "name": "BSwitch", + "integration_type": "virtual", + "supported_by": "switchbee" }, "bt_home_hub_5": { + "name": "BT Home Hub 5", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "BT Home Hub 5" + "iot_class": "local_polling" }, "bt_smarthub": { + "name": "BT Smart Hub", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "BT Smart Hub" + "iot_class": "local_polling" }, "bthome": { + "name": "BTHome", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "BTHome" + "iot_class": "local_push" + }, + "bticino": { + "name": "BTicino", + "integration_type": "virtual", + "supported_by": "netatmo" + }, + "bubendorff": { + "name": "Bubendorff", + "integration_type": "virtual", + "supported_by": "netatmo" }, "buienradar": { + "name": "Buienradar", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Buienradar" + "iot_class": "cloud_polling" }, "caldav": { + "name": "CalDAV", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "CalDAV" + "iot_class": "cloud_polling" }, "canary": { + "name": "Canary", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Canary" + "iot_class": "cloud_polling" }, "cert_expiry": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, "channels": { + "name": "Channels", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Channels" + "iot_class": "local_polling" }, "circuit": { + "name": "Unify Circuit", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Unify Circuit" + "iot_class": "cloud_push" }, "cisco": { "name": "Cisco", "integrations": { "cisco_ios": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Cisco IOS" }, "cisco_mobility_express": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Cisco Mobility Express" }, "cisco_webex_teams": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Cisco Webex Teams" } } }, "citybikes": { + "name": "CityBikes", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "CityBikes" + "iot_class": "cloud_polling" }, "clementine": { + "name": "Clementine Music Player", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Clementine Music Player" + "iot_class": "local_polling" }, "clickatell": { + "name": "Clickatell", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Clickatell" + "iot_class": "cloud_push" }, "clicksend": { "name": "ClickSend", "integrations": { "clicksend": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "ClickSend SMS" }, "clicksend_tts": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "ClickSend TTS" } } }, - "cloud": { - "config_flow": false, - "iot_class": "cloud_push", - "name": "Home Assistant Cloud" - }, "cloudflare": { + "name": "Cloudflare", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Cloudflare" + "iot_class": "cloud_push" }, "cmus": { + "name": "cmus", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "cmus" + "iot_class": "local_polling" }, "co2signal": { + "name": "CO2 Signal", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "CO2 Signal" + "iot_class": "cloud_polling" }, "coinbase": { + "name": "Coinbase", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Coinbase" + "iot_class": "cloud_polling" }, "color_extractor": { - "config_flow": false, - "iot_class": null, - "name": "ColorExtractor" + "name": "ColorExtractor", + "integration_type": "hub", + "config_flow": false }, "comed_hourly_pricing": { + "name": "ComEd Hourly Pricing", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "ComEd Hourly Pricing" + "iot_class": "cloud_polling" }, "comfoconnect": { + "name": "Zehnder ComfoAir Q", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Zehnder ComfoAir Q" + "iot_class": "local_push" }, "command_line": { + "name": "Command Line", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Command Line" + "iot_class": "local_polling" }, "compensation": { + "name": "Compensation", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Compensation" + "iot_class": "calculated" }, "concord232": { + "name": "Concord232", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Concord232" + "iot_class": "local_polling" }, "control4": { + "name": "Control4", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Control4" + "iot_class": "local_polling" }, "coolmaster": { + "name": "CoolMasterNet", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "CoolMasterNet" + "iot_class": "local_polling" }, "coronavirus": { + "name": "Coronavirus (COVID-19)", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Coronavirus (COVID-19)" + "iot_class": "cloud_polling" + }, + "cozytouch": { + "name": "Atlantic Cozytouch", + "integration_type": "virtual", + "supported_by": "overkiz" }, "cpuspeed": { + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, "crownstone": { + "name": "Crownstone", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Crownstone" + "iot_class": "cloud_push" }, "cups": { + "name": "CUPS", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "CUPS" + "iot_class": "local_polling" }, "currencylayer": { + "name": "currencylayer", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "currencylayer" + "iot_class": "cloud_polling" + }, + "dacia": { + "name": "Dacia", + "integration_type": "virtual", + "supported_by": "renault" }, "daikin": { + "name": "Daikin AC", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Daikin AC" + "iot_class": "local_polling" }, "danfoss_air": { + "name": "Danfoss Air", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Danfoss Air" + "iot_class": "local_polling" }, "darksky": { + "name": "Dark Sky", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Dark Sky" + "iot_class": "cloud_polling" }, "datadog": { + "name": "Datadog", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Datadog" + "iot_class": "local_push" }, "ddwrt": { + "name": "DD-WRT", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "DD-WRT" + "iot_class": "local_polling" }, "debugpy": { + "name": "Remote Python Debugger", + "integration_type": "service", "config_flow": false, - "iot_class": "local_push", - "name": "Remote Python Debugger" + "iot_class": "local_push" }, "deconz": { + "name": "deCONZ", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "deCONZ" + "iot_class": "local_push" }, "decora": { + "name": "Leviton Decora", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Leviton Decora" + "iot_class": "local_polling" }, "decora_wifi": { + "name": "Leviton Decora Wi-Fi", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Leviton Decora Wi-Fi" + "iot_class": "cloud_polling" }, "delijn": { + "name": "De Lijn", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "De Lijn" + "iot_class": "cloud_polling" }, "deluge": { + "name": "Deluge", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Deluge" + "iot_class": "local_polling" }, "demo": { + "integration_type": "hub", "config_flow": false, "iot_class": "calculated" }, @@ -754,16 +943,18 @@ "name": "Denon", "integrations": { "denon": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Denon Network Receivers" }, "denonavr": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Denon AVR Network Receivers" }, "heos": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Denon HEOS" @@ -771,79 +962,106 @@ } }, "deutsche_bahn": { + "name": "Deutsche Bahn", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Deutsche Bahn" + "iot_class": "cloud_polling" }, "device_sun_light_trigger": { + "name": "Presence-based Lights", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Presence-based Lights" + "iot_class": "calculated" }, "devolo": { "name": "devolo", "integrations": { "devolo_home_control": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "devolo Home Control" }, "devolo_home_network": { + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "devolo Home Network" } - } + }, + "iot_standards": [ + "zwave" + ] }, "dexcom": { + "name": "Dexcom", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Dexcom" + "iot_class": "cloud_polling" + }, + "diaz": { + "name": "Diaz", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, + "digital_loggers": { + "name": "Digital Loggers", + "integration_type": "virtual", + "supported_by": "wemo" }, "digital_ocean": { + "name": "Digital Ocean", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Digital Ocean" + "iot_class": "local_polling" }, "directv": { + "name": "DirecTV", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "DirecTV" + "iot_class": "local_polling" }, "discogs": { + "name": "Discogs", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Discogs" + "iot_class": "cloud_polling" }, "discord": { + "name": "Discord", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Discord" + "iot_class": "cloud_push" }, "dlib_face_detect": { + "name": "Dlib Face Detect", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Dlib Face Detect" + "iot_class": "local_push" }, "dlib_face_identify": { + "name": "Dlib Face Identify", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Dlib Face Identify" + "iot_class": "local_push" }, "dlink": { + "name": "D-Link Wi-Fi Smart Plugs", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "D-Link Wi-Fi Smart Plugs" + "iot_class": "local_polling" }, "dlna": { "name": "DLNA", "integrations": { "dlna_dmr": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "DLNA Digital Media Renderer" }, "dlna_dms": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "DLNA Digital Media Server" @@ -851,154 +1069,187 @@ } }, "dnsip": { + "name": "DNS IP", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "DNS IP" + "iot_class": "cloud_polling" }, "dominos": { + "name": "Dominos Pizza", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Dominos Pizza" + "iot_class": "cloud_polling" }, "doods": { + "name": "DOODS - Dedicated Open Object Detection Service", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "DOODS - Dedicated Open Object Detection Service" + "iot_class": "local_polling" }, "doorbird": { + "name": "DoorBird", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "DoorBird" + "iot_class": "local_push" + }, + "dooya": { + "name": "Dooya", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "dovado": { + "name": "Dovado", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Dovado" + "iot_class": "local_polling" }, "downloader": { - "config_flow": false, - "iot_class": null, - "name": "Downloader" + "name": "Downloader", + "integration_type": "hub", + "config_flow": false }, "dsmr": { + "name": "DSMR Slimme Meter", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "DSMR Slimme Meter" + "iot_class": "local_push" }, "dsmr_reader": { + "name": "DSMR Reader", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "DSMR Reader" + "iot_class": "local_push" }, "dte_energy_bridge": { + "name": "DTE Energy Bridge", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "DTE Energy Bridge" + "iot_class": "local_polling" }, "dublin_bus_transport": { + "name": "Dublin Bus", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Dublin Bus" + "iot_class": "cloud_polling" }, "duckdns": { + "name": "Duck DNS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Duck DNS" + "iot_class": "cloud_polling" }, "dunehd": { + "name": "Dune HD", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Dune HD" + "iot_class": "local_polling" }, "dwd_weather_warnings": { + "name": "Deutscher Wetterdienst (DWD) Weather Warnings", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Deutscher Wetterdienst (DWD) Weather Warnings" + "iot_class": "cloud_polling" }, "dweet": { + "name": "dweet.io", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "dweet.io" + "iot_class": "cloud_polling" }, "eafm": { + "name": "Environment Agency Flood Gauges", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Environment Agency Flood Gauges" + "iot_class": "cloud_polling" }, "ebox": { + "name": "EBox", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "EBox" + "iot_class": "cloud_polling" }, "ebusd": { + "name": "ebusd", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "ebusd" + "iot_class": "local_polling" }, "ecoal_boiler": { + "name": "eSterownik eCoal.pl Boiler", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "eSterownik eCoal.pl Boiler" + "iot_class": "local_polling" }, "ecobee": { + "name": "ecobee", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "ecobee" + "iot_class": "cloud_polling" }, "econet": { + "name": "Rheem EcoNet Products", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Rheem EcoNet Products" + "iot_class": "cloud_push" }, "ecovacs": { + "name": "Ecovacs", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Ecovacs" + "iot_class": "cloud_push" }, "ecowitt": { + "name": "Ecowitt", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Ecowitt" + "iot_class": "local_push" }, "eddystone_temperature": { + "name": "Eddystone", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Eddystone" + "iot_class": "local_polling" }, "edimax": { + "name": "Edimax", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Edimax" + "iot_class": "local_polling" }, "edl21": { + "name": "EDL21", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "EDL21" + "iot_class": "local_push" }, "efergy": { + "name": "Efergy", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Efergy" + "iot_class": "cloud_polling" }, "egardia": { + "name": "Egardia", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Egardia" + "iot_class": "local_polling" }, "eight_sleep": { + "name": "Eight Sleep", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Eight Sleep" + "iot_class": "cloud_polling" }, "elgato": { "name": "Elgato", "integrations": { "avea": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Elgato Avea" }, "elgato": { + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "Elgato Light" @@ -1006,109 +1257,126 @@ } }, "eliqonline": { + "name": "Eliqonline", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Eliqonline" + "iot_class": "cloud_polling" }, "elkm1": { + "name": "Elk-M1 Control", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Elk-M1 Control" + "iot_class": "local_push" }, "elmax": { + "name": "Elmax", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Elmax" + "iot_class": "cloud_polling" }, "elv": { + "name": "ELV PCA", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "ELV PCA" + "iot_class": "local_polling" }, "emby": { + "name": "Emby", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Emby" + "iot_class": "local_push" }, "emoncms": { "name": "emoncms", "integrations": { "emoncms": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Emoncms" }, "emoncms_history": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Emoncms History" } } }, "emonitor": { + "name": "SiteSage Emonitor", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SiteSage Emonitor" + "iot_class": "local_polling" }, "emulated_hue": { + "name": "Emulated Hue", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Emulated Hue" + "iot_class": "local_push" }, "emulated_kasa": { + "name": "Emulated Kasa", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Emulated Kasa" + "iot_class": "local_push" }, "emulated_roku": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push" }, "enigma2": { + "name": "Enigma2 (OpenWebif)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Enigma2 (OpenWebif)" + "iot_class": "local_polling" }, "enocean": { + "name": "EnOcean", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "EnOcean" + "iot_class": "local_push" }, "enphase_envoy": { + "name": "Enphase Envoy", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Enphase Envoy" + "iot_class": "local_polling" }, "entur_public_transport": { + "name": "Entur", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Entur" + "iot_class": "cloud_polling" }, "environment_canada": { + "name": "Environment Canada", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Environment Canada" + "iot_class": "cloud_polling" }, "envisalink": { + "name": "Envisalink", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Envisalink" + "iot_class": "local_push" }, "ephember": { + "name": "EPH Controls", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "EPH Controls" + "iot_class": "local_polling" }, "epson": { "name": "Epson", "integrations": { "epson": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Epson" }, "epsonworkforce": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Epson Workforce" } @@ -1118,285 +1386,339 @@ "name": "eQ-3", "integrations": { "eq3btsmart": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "eQ-3 Bluetooth Smart Thermostats" }, "maxcube": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "eQ-3 MAX!" } } }, "escea": { + "name": "Escea", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Escea" + "iot_class": "local_push" }, "esphome": { + "name": "ESPHome", + "integration_type": "device", "config_flow": true, - "iot_class": "local_push", - "name": "ESPHome" + "iot_class": "local_push" }, "etherscan": { + "name": "Etherscan", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Etherscan" + "iot_class": "cloud_polling" }, "eufy": { + "name": "eufy", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "eufy" + "iot_class": "local_polling" }, "everlights": { + "name": "EverLights", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "EverLights" + "iot_class": "local_polling" }, "evil_genius_labs": { + "name": "Evil Genius Labs", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Evil Genius Labs" + "iot_class": "local_polling" }, "ezviz": { + "name": "EZVIZ", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "EZVIZ" + "iot_class": "cloud_polling" }, "faa_delays": { + "name": "FAA Delays", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "FAA Delays" + "iot_class": "cloud_polling" }, "facebook": { + "name": "Facebook Messenger", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Facebook Messenger" + "iot_class": "cloud_push" }, "facebox": { + "name": "Facebox", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Facebox" + "iot_class": "local_push" }, "fail2ban": { + "name": "Fail2Ban", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Fail2Ban" + "iot_class": "local_polling" }, "fastdotcom": { + "name": "Fast.com", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Fast.com" + "iot_class": "cloud_polling" }, "feedreader": { + "name": "Feedreader", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Feedreader" + "iot_class": "cloud_polling" }, "ffmpeg": { "name": "FFmpeg", "integrations": { "ffmpeg": { - "config_flow": false, - "iot_class": null, + "integration_type": "hub", "name": "FFmpeg" }, "ffmpeg_motion": { - "config_flow": false, + "integration_type": "hub", "iot_class": "calculated", "name": "FFmpeg Motion" }, "ffmpeg_noise": { - "config_flow": false, + "integration_type": "hub", "iot_class": "calculated", "name": "FFmpeg Noise" } } }, "fibaro": { + "name": "Fibaro", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Fibaro" + "iot_class": "local_push" }, "fido": { + "name": "Fido", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Fido" + "iot_class": "cloud_polling" }, "file": { + "name": "File", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "File" + "iot_class": "local_polling" }, "filesize": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" }, "filter": { + "name": "Filter", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Filter" + "iot_class": "local_push" }, "fints": { + "name": "FinTS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "FinTS" + "iot_class": "cloud_polling" }, "fireservicerota": { + "name": "FireServiceRota", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "FireServiceRota" + "iot_class": "cloud_polling" }, "firmata": { + "name": "Firmata", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Firmata" + "iot_class": "local_push" }, "fitbit": { + "name": "Fitbit", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Fitbit" + "iot_class": "cloud_polling" }, "fivem": { + "name": "FiveM", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "FiveM" + "iot_class": "local_polling" }, "fixer": { + "name": "Fixer", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Fixer" + "iot_class": "cloud_polling" }, "fjaraskupan": { + "name": "Fj\u00e4r\u00e5skupan", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Fj\u00e4r\u00e5skupan" + "iot_class": "local_polling" }, "fleetgo": { + "name": "FleetGO", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "FleetGO" + "iot_class": "cloud_polling" }, "flexit": { + "name": "Flexit", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Flexit" + "iot_class": "local_polling" + }, + "flexom": { + "name": "Bouygues Flexom", + "integration_type": "virtual", + "supported_by": "overkiz" }, "flic": { + "name": "Flic", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Flic" + "iot_class": "local_push" }, "flick_electric": { + "name": "Flick Electric", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Flick Electric" + "iot_class": "cloud_polling" }, "flipr": { + "name": "Flipr", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Flipr" + "iot_class": "cloud_polling" }, "flo": { + "name": "Flo", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Flo" + "iot_class": "cloud_polling" }, "flock": { + "name": "Flock", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Flock" + "iot_class": "cloud_push" }, "flume": { + "name": "Flume", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Flume" + "iot_class": "cloud_polling" }, "flux": { + "name": "Flux", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Flux" + "iot_class": "calculated" }, "flux_led": { + "name": "Magic Home", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Magic Home" + "iot_class": "local_push" }, "folder": { + "name": "Folder", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Folder" + "iot_class": "local_polling" }, "folder_watcher": { + "name": "Folder Watcher", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Folder Watcher" + "iot_class": "local_polling" }, "foobot": { + "name": "Foobot", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Foobot" + "iot_class": "cloud_polling" }, "forecast_solar": { + "name": "Forecast.Solar", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Forecast.Solar" + "iot_class": "cloud_polling" }, "forked_daapd": { + "name": "Owntone", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "forked-daapd" + "iot_class": "local_push" }, "fortios": { + "name": "FortiOS", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "FortiOS" + "iot_class": "local_polling" }, "foscam": { + "name": "Foscam", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Foscam" + "iot_class": "local_polling" }, "foursquare": { + "name": "Foursquare", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Foursquare" + "iot_class": "cloud_push" }, "free_mobile": { + "name": "Free Mobile", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Free Mobile" + "iot_class": "cloud_push" }, "freebox": { + "name": "Freebox", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Freebox" + "iot_class": "local_polling" }, "freedns": { + "name": "FreeDNS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "FreeDNS" + "iot_class": "cloud_push" }, "freedompro": { + "name": "Freedompro", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Freedompro" + "iot_class": "cloud_polling" }, "fritzbox": { "name": "FRITZ!Box", "integrations": { "fritz": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "AVM FRITZ!Box Tools" }, "fritzbox": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "AVM FRITZ!SmartHome" }, "fritzbox_callmonitor": { + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "AVM FRITZ!Box Call Monitor" @@ -1404,88 +1726,110 @@ } }, "fronius": { + "name": "Fronius", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Fronius" + "iot_class": "local_polling" }, "frontier_silicon": { + "name": "Frontier Silicon", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Frontier Silicon" + "iot_class": "local_polling" }, "fully_kiosk": { + "name": "Fully Kiosk Browser", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Fully Kiosk Browser" + "iot_class": "local_polling" }, "futurenow": { + "name": "P5 FutureNow", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "P5 FutureNow" + "iot_class": "local_polling" }, "garadget": { + "name": "Garadget", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Garadget" + "iot_class": "cloud_polling" }, "garages_amsterdam": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, + "gaviota": { + "name": "Gaviota", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "gdacs": { + "name": "Global Disaster Alert and Coordination System (GDACS)", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Global Disaster Alert and Coordination System (GDACS)" + "iot_class": "cloud_polling" }, "generic": { + "name": "Generic Camera", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Generic Camera" + "iot_class": "local_push" }, "generic_hygrostat": { + "name": "Generic hygrostat", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Generic hygrostat" + "iot_class": "local_polling" }, "generic_thermostat": { + "name": "Generic Thermostat", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Generic Thermostat" + "iot_class": "local_polling" }, "geniushub": { + "name": "Genius Hub", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Genius Hub" + "iot_class": "local_polling" }, "geo_json_events": { + "name": "GeoJSON", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "GeoJSON" + "iot_class": "cloud_polling" }, "geo_rss_events": { + "name": "GeoRSS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "GeoRSS" + "iot_class": "cloud_polling" }, "geocaching": { + "name": "Geocaching", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Geocaching" + "iot_class": "cloud_polling" }, "geofency": { + "name": "Geofency", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Geofency" + "iot_class": "cloud_push" }, "geonet": { "name": "GeoNet", "integrations": { "geonetnz_quakes": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "GeoNet NZ Quakes" }, "geonetnz_volcano": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "GeoNet NZ Volcano" @@ -1493,133 +1837,149 @@ } }, "gios": { + "name": "GIO\u015a", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "GIO\u015a" + "iot_class": "cloud_polling" }, "github": { + "name": "GitHub", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "GitHub" + "iot_class": "cloud_polling" }, "gitlab_ci": { + "name": "GitLab-CI", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "GitLab-CI" + "iot_class": "cloud_polling" }, "gitter": { + "name": "Gitter", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Gitter" + "iot_class": "cloud_polling" }, "glances": { + "name": "Glances", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Glances" + "iot_class": "local_polling" }, "globalcache": { "name": "Global Cach\u00e9", "integrations": { "gc100": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Global Cach\u00e9 GC-100" }, "itach": { - "config_flow": false, + "integration_type": "hub", "iot_class": "assumed_state", "name": "Global Cach\u00e9 iTach TCP/IP to IR" } } }, "goalfeed": { + "name": "Goalfeed", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Goalfeed" + "iot_class": "cloud_push" }, "goalzero": { + "name": "Goal Zero Yeti", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Goal Zero Yeti" + "iot_class": "local_polling" }, "gogogate2": { + "name": "Gogogate2 and ismartgate", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Gogogate2 and ismartgate" + "iot_class": "local_polling" }, "goodwe": { + "name": "GoodWe Inverter", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "GoodWe Inverter" + "iot_class": "local_polling" }, "google": { "name": "Google", "integrations": { "google_assistant": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Google Assistant" }, "google_cloud": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Google Cloud Platform" }, "google_domains": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Google Domains" }, "google_maps": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Google Maps" }, "google_pubsub": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Google Pub/Sub" }, "google_sheets": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Google Sheets" }, "google_translate": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Google Translate Text-to-Speech" }, "google_travel_time": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, "google_wifi": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Google Wifi" }, "google": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Google Calendar" }, "nest": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "Google Nest" }, "cast": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Google Cast" }, "hangouts": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "Google Chat" }, "dialogflow": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "Dialogflow" @@ -1627,163 +1987,200 @@ } }, "govee_ble": { + "name": "Govee Bluetooth", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Govee Bluetooth" + "iot_class": "local_push" }, "gpsd": { + "name": "GPSD", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "GPSD" + "iot_class": "local_polling" }, "gpslogger": { + "name": "GPSLogger", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "GPSLogger" + "iot_class": "cloud_push" }, "graphite": { + "name": "Graphite", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Graphite" + "iot_class": "local_push" }, "gree": { + "name": "Gree Climate", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Gree Climate" + "iot_class": "local_polling" }, "greeneye_monitor": { + "name": "GreenEye Monitor (GEM)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "GreenEye Monitor (GEM)" + "iot_class": "local_push" }, "greenwave": { + "name": "Greenwave Reality", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Greenwave Reality" + "iot_class": "local_polling" }, "growatt_server": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, "gstreamer": { + "name": "GStreamer", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "GStreamer" + "iot_class": "local_push" }, "gtfs": { + "name": "General Transit Feed Specification (GTFS)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "General Transit Feed Specification (GTFS)" + "iot_class": "local_polling" }, "guardian": { + "name": "Elexa Guardian", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "Elexa Guardian" + "iot_class": "local_polling" }, "habitica": { + "name": "Habitica", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Habitica" + "iot_class": "cloud_polling" }, "harman_kardon_avr": { + "name": "Harman Kardon AVR", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Harman Kardon AVR" + "iot_class": "local_polling" }, "hassio": { + "name": "Home Assistant Supervisor", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Home Assistant Supervisor" + "iot_class": "local_polling" + }, + "havana_shade": { + "name": "Havana Shade", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "haveibeenpwned": { + "name": "HaveIBeenPwned", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "HaveIBeenPwned" + "iot_class": "cloud_polling" }, "hddtemp": { + "name": "hddtemp", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "hddtemp" + "iot_class": "local_polling" }, "hdmi_cec": { + "name": "HDMI-CEC", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "HDMI-CEC" + "iot_class": "local_push" }, "heatmiser": { + "name": "Heatmiser", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Heatmiser" + "iot_class": "local_polling" + }, + "heiwa": { + "name": "Heiwa", + "integration_type": "virtual", + "supported_by": "gree" }, "here_travel_time": { + "name": "HERE Travel Time", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "HERE Travel Time" + "iot_class": "cloud_polling" + }, + "hi_kumo": { + "name": "Hitachi Hi Kumo", + "integration_type": "virtual", + "supported_by": "overkiz" }, "hikvision": { "name": "Hikvision", "integrations": { "hikvision": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "Hikvision" }, "hikvisioncam": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Hikvision" } } }, "hisense_aehw4a1": { + "name": "Hisense AEH-W4A1", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Hisense AEH-W4A1" + "iot_class": "local_polling" }, "history_stats": { + "name": "History Stats", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "History Stats" + "iot_class": "local_polling" }, "hitron_coda": { + "name": "Rogers Hitron CODA", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Rogers Hitron CODA" + "iot_class": "local_polling" }, "hive": { + "name": "Hive", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Hive" + "iot_class": "cloud_polling" }, "hlk_sw16": { + "name": "Hi-Link HLK-SW16", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Hi-Link HLK-SW16" + "iot_class": "local_push" }, "home_connect": { + "name": "Home Connect", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Home Connect" + "iot_class": "cloud_push" }, "home_plus_control": { + "name": "Legrand Home+ Control", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Legrand Home+ Control" - }, - "homeassistant_alerts": { - "config_flow": false, - "iot_class": null, - "name": "Home Assistant Alerts" + "iot_class": "cloud_polling" }, "homematic": { "name": "Homematic", "integrations": { "homematic": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "Homematic" }, "homematicip_cloud": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "HomematicIP Cloud" @@ -1791,24 +2188,27 @@ } }, "homewizard": { + "name": "HomeWizard Energy", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "HomeWizard Energy" + "iot_class": "local_polling" }, "honeywell": { "name": "Honeywell", "integrations": { "lyric": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Honeywell Lyric" }, "evohome": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Honeywell Total Connect Comfort (Europe)" }, "honeywell": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Honeywell Total Connect Comfort (US)" @@ -1816,129 +2216,172 @@ } }, "horizon": { + "name": "Unitymedia Horizon HD Recorder", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Unitymedia Horizon HD Recorder" + "iot_class": "local_polling" }, "hp_ilo": { + "name": "HP Integrated Lights-Out (ILO)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "HP Integrated Lights-Out (ILO)" + "iot_class": "local_polling" }, "html5": { + "name": "HTML5 Push Notifications", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "HTML5 Push Notifications" + "iot_class": "cloud_push" }, "huawei_lte": { + "name": "Huawei LTE", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Huawei LTE" + "iot_class": "local_polling" }, "huisbaasje": { + "name": "Huisbaasje", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Huisbaasje" + "iot_class": "cloud_polling" }, "hunterdouglas_powerview": { + "name": "Hunter Douglas PowerView", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Hunter Douglas PowerView" + "iot_class": "local_polling" + }, + "hurrican_shutters_wholesale": { + "name": "Hurrican Shutters Wholesale", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "hvv_departures": { + "name": "HVV Departures", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "HVV Departures" + "iot_class": "cloud_polling" }, "hydrawise": { + "name": "Hunter Hydrawise", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Hunter Hydrawise" + "iot_class": "cloud_polling" }, "hyperion": { + "name": "Hyperion", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Hyperion" + "iot_class": "local_push" }, "ialarm": { + "name": "Antifurto365 iAlarm", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Antifurto365 iAlarm" + "iot_class": "local_polling" }, "iammeter": { + "name": "IamMeter", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "IamMeter" + "iot_class": "local_polling" }, "iaqualink": { + "name": "Jandy iAqualink", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Jandy iAqualink" + "iot_class": "cloud_polling" }, "ibm": { "name": "IBM", "integrations": { "watson_iot": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "IBM Watson IoT Platform" }, "watson_tts": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "IBM Watson TTS" } } }, "idteck_prox": { + "name": "IDTECK Proximity Reader", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "IDTECK Proximity Reader" + "iot_class": "local_push" }, "ifttt": { + "name": "IFTTT", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "IFTTT" + "iot_class": "cloud_push" }, "iglo": { + "name": "iGlo", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "iGlo" + "iot_class": "local_polling" }, "ign_sismologia": { + "name": "IGN Sismolog\u00eda", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "IGN Sismolog\u00eda" + "iot_class": "cloud_polling" }, "ihc": { + "name": "IHC Controller", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "IHC Controller" + "iot_class": "local_push" + }, + "ikea": { + "name": "IKEA", + "integrations": { + "symfonisk": { + "integration_type": "virtual", + "supported_by": "sonos", + "name": "IKEA SYMFONISK" + }, + "tradfri": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "IKEA TR\u00c5DFRI" + } + } }, "imap": { + "name": "IMAP", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "IMAP" + "iot_class": "cloud_push" }, "imap_email_content": { + "name": "IMAP Email Content", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "IMAP Email Content" + "iot_class": "cloud_push" }, "incomfort": { + "name": "Intergas InComfort/Intouch Lan2RF gateway", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Intergas InComfort/Intouch Lan2RF gateway" + "iot_class": "local_polling" }, "influxdb": { + "name": "InfluxDB", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "InfluxDB" + "iot_class": "local_push" }, "inkbird": { + "name": "INKBIRD", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "INKBIRD" + "iot_class": "local_push" }, "inovelli": { "name": "Inovelli", @@ -1947,79 +2390,103 @@ "zwave" ] }, + "inspired_shades": { + "name": "Inspired Shades", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "insteon": { + "name": "Insteon", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Insteon" + "iot_class": "local_push" }, "intellifire": { + "name": "IntelliFire", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "IntelliFire" + "iot_class": "local_polling" }, "intent_script": { - "config_flow": false, - "iot_class": null, - "name": "Intent Script" + "name": "Intent Script", + "integration_type": "hub", + "config_flow": false }, "intesishome": { + "name": "IntesisHome", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "IntesisHome" + "iot_class": "cloud_push" }, "ios": { + "name": "Home Assistant iOS", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Home Assistant iOS" + "iot_class": "cloud_push" }, "iotawatt": { + "name": "IoTaWatt", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "IoTaWatt" + "iot_class": "local_polling" }, "iperf3": { + "name": "Iperf3", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Iperf3" + "iot_class": "local_polling" }, "ipma": { + "name": "Instituto Portugu\u00eas do Mar e Atmosfera (IPMA)", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Instituto Portugu\u00eas do Mar e Atmosfera (IPMA)" + "iot_class": "cloud_polling" }, "ipp": { + "name": "Internet Printing Protocol (IPP)", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "Internet Printing Protocol (IPP)" + "iot_class": "local_polling" }, "iqvia": { + "name": "IQVIA", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "IQVIA" + "iot_class": "cloud_polling" }, "irish_rail_transport": { + "name": "Irish Rail Transport", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Irish Rail Transport" + "iot_class": "cloud_polling" }, "islamic_prayer_times": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, + "ismartwindow": { + "name": "iSmartWindow", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "iss": { + "name": "International Space Station (ISS)", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "International Space Station (ISS)" + "iot_class": "cloud_polling" }, "isy994": { + "name": "Universal Devices ISY994", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Universal Devices ISY994" + "iot_class": "local_push" }, "izone": { + "name": "iZone", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "iZone" + "iot_class": "local_polling" }, "jasco": { "name": "Jasco", @@ -2028,179 +2495,219 @@ ] }, "jellyfin": { + "name": "Jellyfin", + "integration_type": "service", "config_flow": true, - "iot_class": "local_polling", - "name": "Jellyfin" + "iot_class": "local_polling" }, "jewish_calendar": { + "name": "Jewish Calendar", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Jewish Calendar" + "iot_class": "calculated" }, "joaoapps_join": { + "name": "Joaoapps Join", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Joaoapps Join" + "iot_class": "cloud_push" }, "juicenet": { + "name": "JuiceNet", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "JuiceNet" + "iot_class": "cloud_polling" }, "justnimbus": { + "name": "JustNimbus", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "JustNimbus" + "iot_class": "cloud_polling" }, "kaiterra": { + "name": "Kaiterra", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Kaiterra" + "iot_class": "cloud_polling" }, "kaleidescape": { + "name": "Kaleidescape", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Kaleidescape" + "iot_class": "local_push" }, "kankun": { + "name": "Kankun", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Kankun" + "iot_class": "local_polling" }, "keba": { + "name": "Keba Charging Station", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Keba Charging Station" + "iot_class": "local_polling" }, "keenetic_ndms2": { + "name": "Keenetic NDMS2 Router", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Keenetic NDMS2 Router" + "iot_class": "local_polling" }, "kef": { + "name": "KEF", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "KEF" + "iot_class": "local_polling" }, "kegtron": { + "name": "Kegtron", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Kegtron" + "iot_class": "local_push" }, "keyboard": { + "name": "Keyboard", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Keyboard" + "iot_class": "local_push" }, "keyboard_remote": { + "name": "Keyboard Remote", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Keyboard Remote" + "iot_class": "local_push" }, "keymitt_ble": { + "name": "Keymitt MicroBot Push", + "integration_type": "hub", "config_flow": true, - "iot_class": "assumed_state", - "name": "Keymitt MicroBot Push" + "iot_class": "assumed_state" }, "kira": { + "name": "Kira", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Kira" + "iot_class": "local_push" }, "kiwi": { + "name": "KIWI", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "KIWI" + "iot_class": "cloud_polling" }, "kmtronic": { + "name": "KMtronic", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "KMtronic" + "iot_class": "local_push" }, "knx": { + "name": "KNX", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "KNX" + "iot_class": "local_push" }, "kodi": { + "name": "Kodi", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Kodi" + "iot_class": "local_push" }, "konnected": { + "name": "Konnected.io", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Konnected.io" + "iot_class": "local_push" }, "kostal_plenticore": { + "name": "Kostal Plenticore Solar Inverter", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Kostal Plenticore Solar Inverter" + "iot_class": "local_polling" }, "kraken": { + "name": "Kraken", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Kraken" + "iot_class": "cloud_polling" }, "kulersky": { + "name": "Kuler Sky", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Kuler Sky" + "iot_class": "local_polling" }, "kwb": { + "name": "KWB Easyfire", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "KWB Easyfire" + "iot_class": "local_polling" }, "lacrosse": { + "name": "LaCrosse", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "LaCrosse" + "iot_class": "local_polling" }, "lacrosse_view": { + "name": "LaCrosse View", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "LaCrosse View" + "iot_class": "cloud_polling" }, "lametric": { + "name": "LaMetric", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "LaMetric" + "iot_class": "local_polling" }, "landisgyr_heat_meter": { + "name": "Landis+Gyr Heat Meter", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Landis+Gyr Heat Meter" + "iot_class": "local_polling" }, "lannouncer": { + "name": "LANnouncer", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "LANnouncer" + "iot_class": "local_push" }, "lastfm": { + "name": "Last.fm", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Last.fm" + "iot_class": "cloud_polling" }, "launch_library": { + "name": "Launch Library", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Launch Library" + "iot_class": "cloud_polling" }, "laundrify": { + "name": "laundrify", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "laundrify" + "iot_class": "cloud_polling" }, "lcn": { + "name": "LCN", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "LCN" + "iot_class": "local_push" }, "led_ble": { + "name": "LED BLE", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "LED BLE" + "iot_class": "local_polling" + }, + "legrand": { + "name": "Legrand", + "integration_type": "virtual", + "supported_by": "netatmo" }, "leviton": { "name": "Leviton", @@ -2212,16 +2719,18 @@ "name": "LG", "integrations": { "lg_netcast": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "LG Netcast" }, "lg_soundbar": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "LG Soundbars" }, "webostv": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "LG webOS Smart TV" @@ -2229,108 +2738,128 @@ } }, "lidarr": { + "name": "Lidarr", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Lidarr" + "iot_class": "local_polling" }, "life360": { + "name": "Life360", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Life360" + "iot_class": "cloud_polling" }, "lifx": { + "name": "LIFX", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "LIFX" + "iot_class": "local_polling" }, "lifx_cloud": { + "name": "LIFX Cloud", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "LIFX Cloud" + "iot_class": "cloud_push" }, "lightwave": { + "name": "Lightwave", + "integration_type": "hub", "config_flow": false, - "iot_class": "assumed_state", - "name": "Lightwave" + "iot_class": "assumed_state" }, "limitlessled": { + "name": "LimitlessLED", + "integration_type": "hub", "config_flow": false, - "iot_class": "assumed_state", - "name": "LimitlessLED" + "iot_class": "assumed_state" }, "linksys_smart": { + "name": "Linksys Smart Wi-Fi", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Linksys Smart Wi-Fi" + "iot_class": "local_polling" }, "linode": { + "name": "Linode", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Linode" + "iot_class": "cloud_polling" }, "linux_battery": { + "name": "Linux Battery", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Linux Battery" + "iot_class": "local_polling" }, "lirc": { + "name": "LIRC", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "LIRC" + "iot_class": "local_push" }, "litejet": { + "name": "LiteJet", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "LiteJet" + "iot_class": "local_push" }, "litterrobot": { + "name": "Litter-Robot", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Litter-Robot" + "iot_class": "cloud_push" }, "llamalab_automate": { + "name": "LlamaLab Automate", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "LlamaLab Automate" + "iot_class": "cloud_push" }, "local_file": { + "name": "Local File", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Local File" + "iot_class": "local_polling" }, "local_ip": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" }, "locative": { + "name": "Locative", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Locative" + "iot_class": "local_push" }, "logentries": { + "name": "Logentries", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Logentries" + "iot_class": "cloud_push" }, "logi_circle": { + "name": "Logi Circle", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Logi Circle" + "iot_class": "cloud_polling" }, "logitech": { "name": "Logitech", "integrations": { "harmony": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Logitech Harmony Hub" }, "ue_smart_radio": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Logitech UE Smart Radio" }, "squeezebox": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Squeezebox (Logitech Media Server)" @@ -2338,825 +2867,990 @@ } }, "london_air": { + "name": "London Air", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "London Air" + "iot_class": "cloud_polling" }, "london_underground": { + "name": "London Underground", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "London Underground" + "iot_class": "cloud_polling" }, "lookin": { + "name": "LOOKin", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "LOOKin" + "iot_class": "local_push" }, "luftdaten": { + "name": "Sensor.Community", + "integration_type": "device", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Sensor.Community" + "iot_class": "cloud_polling" }, "lupusec": { + "name": "Lupus Electronics LUPUSEC", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Lupus Electronics LUPUSEC" + "iot_class": "local_polling" }, "lutron": { "name": "Lutron", "integrations": { "lutron": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Lutron" }, "lutron_caseta": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Lutron Cas\u00e9ta" }, "homeworks": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "Lutron Homeworks" } } }, + "luxaflex": { + "name": "Luxaflex", + "integration_type": "virtual", + "supported_by": "hunterdouglas_powerview" + }, "lw12wifi": { + "name": "LAGUTE LW-12", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "LAGUTE LW-12" + "iot_class": "local_polling" }, "magicseaweed": { + "name": "Magicseaweed", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Magicseaweed" + "iot_class": "cloud_polling" }, "mailgun": { + "name": "Mailgun", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Mailgun" + "iot_class": "cloud_push" }, "manual": { + "name": "Manual Alarm Control Panel", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Manual Alarm Control Panel" + "iot_class": "calculated" }, - "map": { - "config_flow": false, - "iot_class": null, - "name": "Map" + "marantz": { + "name": "Marantz", + "integration_type": "virtual", + "supported_by": "denonavr" + }, + "martec": { + "name": "Martec", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "marytts": { + "name": "MaryTTS", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "MaryTTS" + "iot_class": "local_push" }, "mastodon": { + "name": "Mastodon", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Mastodon" + "iot_class": "cloud_push" }, "matrix": { + "name": "Matrix", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Matrix" + "iot_class": "cloud_push" }, "mazda": { + "name": "Mazda Connected Services", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Mazda Connected Services" + "iot_class": "cloud_polling" }, "meater": { + "name": "Meater", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Meater" + "iot_class": "cloud_polling" }, "media_extractor": { + "name": "Media Extractor", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Media Extractor" + "iot_class": "calculated" }, "mediaroom": { + "name": "Mediaroom", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Mediaroom" + "iot_class": "local_polling" }, "melcloud": { + "name": "MELCloud", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "MELCloud" + "iot_class": "cloud_polling" }, "melissa": { + "name": "Melissa", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Melissa" + "iot_class": "cloud_polling" }, "melnor": { "name": "Melnor", "integrations": { "melnor": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Melnor Bluetooth" }, "raincloud": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Melnor RainCloud" } } }, "meraki": { + "name": "Meraki", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Meraki" + "iot_class": "cloud_polling" }, "message_bird": { + "name": "MessageBird", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "MessageBird" + "iot_class": "cloud_push" }, "met": { + "name": "Meteorologisk institutt (Met.no)", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Meteorologisk institutt (Met.no)" + "iot_class": "cloud_polling" }, "met_eireann": { + "name": "Met \u00c9ireann", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Met \u00c9ireann" + "iot_class": "cloud_polling" }, "meteo_france": { + "name": "M\u00e9t\u00e9o-France", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "M\u00e9t\u00e9o-France" + "iot_class": "cloud_polling" }, "meteoalarm": { + "name": "MeteoAlarm", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "MeteoAlarm" + "iot_class": "cloud_polling" }, "meteoclimatic": { + "name": "Meteoclimatic", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Meteoclimatic" + "iot_class": "cloud_polling" }, "metoffice": { + "name": "Met Office", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Met Office" + "iot_class": "cloud_polling" }, "mfi": { + "name": "Ubiquiti mFi mPort", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ubiquiti mFi mPort" + "iot_class": "local_polling" }, "microsoft": { "name": "Microsoft", "integrations": { "azure_devops": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Azure DevOps" }, "azure_event_hub": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "Azure Event Hub" }, "azure_service_bus": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Azure Service Bus" }, "microsoft_face_detect": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Microsoft Face Detect" }, "microsoft_face_identify": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Microsoft Face Identify" }, "microsoft_face": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Microsoft Face" }, "microsoft": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Microsoft Text-to-Speech (TTS)" }, "msteams": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Microsoft Teams" }, "xbox": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Xbox" }, "xbox_live": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Xbox Live" } } }, "miflora": { + "name": "Mi Flora", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Mi Flora" + "iot_class": "local_polling" }, "mikrotik": { + "name": "Mikrotik", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Mikrotik" + "iot_class": "local_polling" }, "mill": { + "name": "Mill", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Mill" + "iot_class": "local_polling" }, "minecraft_server": { + "name": "Minecraft Server", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Minecraft Server" + "iot_class": "local_polling" }, "minio": { + "name": "Minio", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Minio" + "iot_class": "cloud_push" }, "mitemp_bt": { + "name": "Xiaomi Mijia BLE Temperature and Humidity Sensor", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Xiaomi Mijia BLE Temperature and Humidity Sensor" + "iot_class": "local_polling" }, "mjpeg": { + "name": "MJPEG IP Camera", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "MJPEG IP Camera" + "iot_class": "local_push" }, "moat": { + "name": "Moat", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Moat" + "iot_class": "local_push" }, "mobile_app": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push" }, "mochad": { + "name": "Mochad", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Mochad" + "iot_class": "local_polling" }, "modbus": { + "name": "Modbus", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Modbus" + "iot_class": "local_polling" }, "modem_callerid": { + "name": "Phone Modem", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Phone Modem" + "iot_class": "local_polling" }, "modern_forms": { + "name": "Modern Forms", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Modern Forms" + "iot_class": "local_polling" }, "moehlenhoff_alpha2": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push" }, "mold_indicator": { + "name": "Mold Indicator", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Mold Indicator" + "iot_class": "local_polling" }, "monoprice": { + "name": "Monoprice 6-Zone Amplifier", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Monoprice 6-Zone Amplifier" + "iot_class": "local_polling" }, "moon": { + "integration_type": "service", "config_flow": true, "iot_class": "local_polling" }, "motion_blinds": { + "name": "Motion Blinds", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Motion Blinds" + "iot_class": "local_push" }, "motioneye": { + "name": "motionEye", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "motionEye" + "iot_class": "local_polling" }, "mpd": { + "name": "Music Player Daemon (MPD)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Music Player Daemon (MPD)" + "iot_class": "local_polling" }, "mqtt": { "name": "MQTT", "integrations": { "manual_mqtt": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "Manual MQTT Alarm Control Panel" }, "mqtt": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "MQTT" }, "mqtt_eventstream": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "MQTT Eventstream" }, "mqtt_json": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "MQTT JSON" }, "mqtt_room": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "MQTT Room Presence" }, "mqtt_statestream": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "MQTT Statestream" } } }, "mullvad": { + "name": "Mullvad VPN", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Mullvad VPN" + "iot_class": "cloud_polling" }, "mutesync": { + "name": "mutesync", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "mutesync" + "iot_class": "local_polling" }, "mvglive": { + "name": "MVG", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "MVG" + "iot_class": "cloud_polling" }, "mycroft": { + "name": "Mycroft", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Mycroft" + "iot_class": "local_push" }, "myq": { + "name": "MyQ", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "MyQ" + "iot_class": "cloud_polling" }, "mysensors": { + "name": "MySensors", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "MySensors" + "iot_class": "local_push" }, "mystrom": { + "name": "myStrom", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "myStrom" + "iot_class": "local_polling" }, "mythicbeastsdns": { + "name": "Mythic Beasts DNS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Mythic Beasts DNS" + "iot_class": "cloud_push" }, "nad": { + "name": "NAD", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "NAD" + "iot_class": "local_polling" }, "nam": { + "name": "Nettigo Air Monitor", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "Nettigo Air Monitor" + "iot_class": "local_polling" }, "namecheapdns": { + "name": "Namecheap FreeDNS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Namecheap FreeDNS" + "iot_class": "cloud_push" }, "nanoleaf": { + "name": "Nanoleaf", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Nanoleaf" + "iot_class": "local_push" }, "neato": { + "name": "Neato Botvac", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Neato Botvac" + "iot_class": "cloud_polling" }, "nederlandse_spoorwegen": { + "name": "Nederlandse Spoorwegen (NS)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Nederlandse Spoorwegen (NS)" + "iot_class": "cloud_polling" }, "ness_alarm": { + "name": "Ness Alarm", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Ness Alarm" + "iot_class": "local_push" }, "netatmo": { + "name": "Netatmo", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Netatmo" + "iot_class": "cloud_polling" }, "netdata": { + "name": "Netdata", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Netdata" + "iot_class": "local_polling" }, "netgear": { "name": "NETGEAR", "integrations": { "netgear": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "NETGEAR" }, "netgear_lte": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "NETGEAR LTE" } } }, "netio": { + "name": "Netio", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Netio" + "iot_class": "local_polling" }, "neurio_energy": { + "name": "Neurio energy", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Neurio energy" + "iot_class": "cloud_polling" }, "nexia": { + "name": "Nexia/American Standard/Trane", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Nexia/American Standard/Trane" + "iot_class": "cloud_polling" + }, + "nexity": { + "name": "Nexity Eug\u00e9nie", + "integration_type": "virtual", + "supported_by": "overkiz" }, "nextbus": { + "name": "NextBus", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "NextBus" + "iot_class": "local_polling" }, "nextcloud": { + "name": "Nextcloud", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Nextcloud" + "iot_class": "cloud_polling" }, "nextdns": { + "name": "NextDNS", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "NextDNS" + "iot_class": "cloud_polling" }, "nfandroidtv": { + "name": "Notifications for Android TV / Fire TV", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Notifications for Android TV / Fire TV" + "iot_class": "local_push" }, "nibe_heatpump": { + "name": "Nibe Heat Pump", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Nibe Heat Pump" + "iot_class": "local_polling" }, "nightscout": { + "name": "Nightscout", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Nightscout" + "iot_class": "cloud_polling" }, "niko_home_control": { + "name": "Niko Home Control", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Niko Home Control" + "iot_class": "local_polling" }, "nilu": { + "name": "Norwegian Institute for Air Research (NILU)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Norwegian Institute for Air Research (NILU)" + "iot_class": "cloud_polling" }, "nina": { + "name": "NINA", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "NINA" + "iot_class": "cloud_polling" }, "nissan_leaf": { + "name": "Nissan Leaf", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Nissan Leaf" + "iot_class": "cloud_polling" }, "nmap_tracker": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" }, "nmbs": { + "name": "NMBS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "NMBS" + "iot_class": "cloud_polling" }, "no_ip": { + "name": "No-IP.com", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "No-IP.com" + "iot_class": "cloud_polling" }, "noaa_tides": { + "name": "NOAA Tides", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "NOAA Tides" + "iot_class": "cloud_polling" }, "nobo_hub": { + "name": "Nob\u00f8 Ecohub", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Nob\u00f8 Ecohub" + "iot_class": "local_push" }, "norway_air": { + "name": "Om Luftkvalitet i Norge (Norway Air)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Om Luftkvalitet i Norge (Norway Air)" + "iot_class": "cloud_polling" }, "notify_events": { + "name": "Notify.Events", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Notify.Events" + "iot_class": "cloud_push" }, "notion": { + "name": "Notion", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Notion" + "iot_class": "cloud_polling" }, "nsw_fuel_station": { + "name": "NSW Fuel Station Price", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "NSW Fuel Station Price" + "iot_class": "cloud_polling" }, "nsw_rural_fire_service_feed": { + "name": "NSW Rural Fire Service Incidents", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "NSW Rural Fire Service Incidents" + "iot_class": "cloud_polling" }, "nuheat": { + "name": "NuHeat", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "NuHeat" + "iot_class": "cloud_polling" }, "nuki": { + "name": "Nuki", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Nuki" + "iot_class": "local_polling" }, "numato": { + "name": "Numato USB GPIO Expander", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Numato USB GPIO Expander" + "iot_class": "local_push" }, "nut": { + "name": "Network UPS Tools (NUT)", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Network UPS Tools (NUT)" + "iot_class": "local_polling" + }, + "nutrichef": { + "name": "Nutrichef", + "integration_type": "virtual", + "supported_by": "inkbird" }, "nws": { + "name": "National Weather Service (NWS)", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "National Weather Service (NWS)" + "iot_class": "cloud_polling" }, "nx584": { + "name": "NX584", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "NX584" + "iot_class": "local_push" }, "nzbget": { + "name": "NZBGet", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "NZBGet" + "iot_class": "local_polling" }, "oasa_telematics": { + "name": "OASA Telematics", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "OASA Telematics" + "iot_class": "cloud_polling" }, "obihai": { + "name": "Obihai", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Obihai" + "iot_class": "local_polling" }, "octoprint": { + "name": "OctoPrint", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "OctoPrint" + "iot_class": "local_polling" }, "oem": { + "name": "OpenEnergyMonitor WiFi Thermostat", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "OpenEnergyMonitor WiFi Thermostat" + "iot_class": "local_polling" }, "ohmconnect": { + "name": "OhmConnect", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "OhmConnect" + "iot_class": "cloud_polling" }, "ombi": { + "name": "Ombi", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ombi" + "iot_class": "local_polling" }, "omnilogic": { + "name": "Hayward Omnilogic", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Hayward Omnilogic" + "iot_class": "cloud_polling" }, "oncue": { + "name": "Oncue by Kohler", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Oncue by Kohler" + "iot_class": "cloud_polling" }, "ondilo_ico": { + "name": "Ondilo ICO", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Ondilo ICO" + "iot_class": "cloud_polling" }, "onewire": { + "name": "1-Wire", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "1-Wire" + "iot_class": "local_polling" }, "onkyo": { + "name": "Onkyo", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Onkyo" + "iot_class": "local_polling" }, "onvif": { + "name": "ONVIF", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "ONVIF" + "iot_class": "local_push" }, "open_meteo": { + "name": "Open-Meteo", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Open-Meteo" + "iot_class": "cloud_polling" }, "openalpr_cloud": { + "name": "OpenALPR Cloud", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "OpenALPR Cloud" + "iot_class": "cloud_push" }, "openalpr_local": { + "name": "OpenALPR Local", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "OpenALPR Local" + "iot_class": "local_push" }, "opencv": { + "name": "OpenCV", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "OpenCV" + "iot_class": "local_push" }, "openerz": { + "name": "Open ERZ", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Open ERZ" + "iot_class": "cloud_polling" }, "openevse": { + "name": "OpenEVSE", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "OpenEVSE" + "iot_class": "local_polling" }, "openexchangerates": { + "name": "Open Exchange Rates", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Open Exchange Rates" + "iot_class": "cloud_polling" }, "opengarage": { + "name": "OpenGarage", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "OpenGarage" + "iot_class": "local_polling" }, "openhardwaremonitor": { + "name": "Open Hardware Monitor", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Open Hardware Monitor" + "iot_class": "local_polling" }, "openhome": { + "name": "Linn / OpenHome", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Linn / OpenHome" + "iot_class": "local_polling" }, "opensensemap": { + "name": "openSenseMap", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "openSenseMap" + "iot_class": "cloud_polling" }, "opensky": { + "name": "OpenSky Network", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "OpenSky Network" + "iot_class": "cloud_polling" }, "opentherm_gw": { + "name": "OpenTherm Gateway", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "OpenTherm Gateway" + "iot_class": "local_push" }, "openuv": { + "name": "OpenUV", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "OpenUV" + "iot_class": "cloud_polling" }, "openweathermap": { + "name": "OpenWeatherMap", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "OpenWeatherMap" + "iot_class": "cloud_polling" }, "openwrt": { "name": "OpenWrt", "integrations": { "luci": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "OpenWrt (luci)" }, "ubus": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "OpenWrt (ubus)" } } }, "opnsense": { + "name": "OPNSense", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "OPNSense" + "iot_class": "local_polling" }, "opple": { + "name": "Opple", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Opple" + "iot_class": "local_polling" + }, + "oralb": { + "name": "Oral-B", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" }, "oru": { + "name": "Orange and Rockland Utility (ORU)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Orange and Rockland Utility (ORU)" + "iot_class": "cloud_polling" }, "orvibo": { + "name": "Orvibo", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Orvibo" + "iot_class": "local_push" }, "osramlightify": { + "name": "Osramlightify", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Osramlightify" + "iot_class": "local_polling" }, "otp": { + "name": "One-Time Password (OTP)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "One-Time Password (OTP)" + "iot_class": "local_polling" }, "overkiz": { + "name": "Overkiz", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Overkiz" + "iot_class": "cloud_polling" }, "ovo_energy": { + "name": "OVO Energy", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "OVO Energy" + "iot_class": "cloud_polling" }, "owntracks": { + "name": "OwnTracks", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "OwnTracks" + "iot_class": "local_push" }, "p1_monitor": { + "name": "P1 Monitor", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "P1 Monitor" + "iot_class": "local_polling" }, "panasonic": { "name": "Panasonic", "integrations": { "panasonic_bluray": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Panasonic Blu-Ray Player" }, "panasonic_viera": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Panasonic Viera" @@ -3164,49 +3858,55 @@ } }, "pandora": { + "name": "Pandora", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Pandora" + "iot_class": "local_polling" }, "panel_custom": { - "config_flow": false, - "iot_class": null, - "name": "Custom Panel" + "name": "Custom Panel", + "integration_type": "hub", + "config_flow": false }, "panel_iframe": { - "config_flow": false, - "iot_class": null, - "name": "iframe Panel" + "name": "iframe Panel", + "integration_type": "hub", + "config_flow": false + }, + "pcs_lighting": { + "name": "PCS Lighting", + "integration_type": "virtual", + "supported_by": "upb" }, "peco": { + "name": "PECO Outage Counter", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "PECO Outage Counter" + "iot_class": "cloud_polling" }, "pencom": { + "name": "Pencom", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Pencom" - }, - "persistent_notification": { - "config_flow": false, - "iot_class": "local_push", - "name": "Persistent Notification" + "iot_class": "local_polling" }, "philips": { "name": "Philips", "integrations": { "dynalite": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Philips Dynalite" }, "hue": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Philips Hue" }, "philips_js": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Philips TV" @@ -3214,202 +3914,237 @@ } }, "pi_hole": { + "name": "Pi-hole", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Pi-hole" + "iot_class": "local_polling" }, "picnic": { + "name": "Picnic", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Picnic" + "iot_class": "cloud_polling" }, "picotts": { + "name": "Pico TTS", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Pico TTS" + "iot_class": "local_push" }, "pilight": { + "name": "Pilight", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Pilight" + "iot_class": "local_push" }, "ping": { + "name": "Ping (ICMP)", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ping (ICMP)" + "iot_class": "local_polling" }, "pioneer": { + "name": "Pioneer", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Pioneer" + "iot_class": "local_polling" }, "pjlink": { + "name": "PJLink", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "PJLink" + "iot_class": "local_polling" }, "plaato": { + "name": "Plaato", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Plaato" + "iot_class": "cloud_push" }, "plant": { - "config_flow": false, - "iot_class": null + "integration_type": "hub", + "config_flow": false }, "plex": { + "name": "Plex Media Server", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Plex Media Server" + "iot_class": "local_push" }, "plugwise": { + "name": "Plugwise", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Plugwise" + "iot_class": "local_polling" }, "plum_lightpad": { + "name": "Plum Lightpad", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Plum Lightpad" + "iot_class": "local_push" }, "pocketcasts": { + "name": "Pocket Casts", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Pocket Casts" + "iot_class": "cloud_polling" }, "point": { + "name": "Minut Point", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Minut Point" + "iot_class": "cloud_polling" }, "poolsense": { + "name": "PoolSense", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "PoolSense" + "iot_class": "cloud_polling" }, "profiler": { - "config_flow": true, - "iot_class": null, - "name": "Profiler" + "name": "Profiler", + "integration_type": "hub", + "config_flow": true }, "progettihwsw": { + "name": "ProgettiHWSW Automation", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "ProgettiHWSW Automation" + "iot_class": "local_polling" }, "proliphix": { + "name": "Proliphix", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Proliphix" + "iot_class": "local_polling" }, "prometheus": { + "name": "Prometheus", + "integration_type": "hub", "config_flow": false, - "iot_class": "assumed_state", - "name": "Prometheus" + "iot_class": "assumed_state" }, "prosegur": { + "name": "Prosegur Alarm", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Prosegur Alarm" + "iot_class": "cloud_polling" }, "prowl": { + "name": "Prowl", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Prowl" + "iot_class": "cloud_push" }, "proximity": { + "integration_type": "hub", "config_flow": false, "iot_class": "calculated" }, "proxmoxve": { + "name": "Proxmox VE", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Proxmox VE" + "iot_class": "local_polling" }, "proxy": { - "config_flow": false, - "iot_class": null, - "name": "Camera Proxy" + "name": "Camera Proxy", + "integration_type": "hub", + "config_flow": false }, "prusalink": { + "name": "PrusaLink", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "PrusaLink" + "iot_class": "local_polling" }, "pulseaudio_loopback": { + "name": "PulseAudio Loopback", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "PulseAudio Loopback" + "iot_class": "local_polling" }, "pure_energie": { + "name": "Pure Energie", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Pure Energie" + "iot_class": "local_polling" }, "push": { + "name": "Push", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Push" + "iot_class": "local_push" }, "pushbullet": { + "name": "Pushbullet", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Pushbullet" + "iot_class": "cloud_polling" }, "pushover": { + "name": "Pushover", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Pushover" + "iot_class": "cloud_push" }, "pushsafer": { + "name": "Pushsafer", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Pushsafer" + "iot_class": "cloud_push" }, "pvoutput": { + "name": "PVOutput", + "integration_type": "device", "config_flow": true, - "iot_class": "cloud_polling", - "name": "PVOutput" + "iot_class": "cloud_polling" }, "pvpc_hourly_pricing": { + "name": "Spain electricity hourly pricing (PVPC)", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Spain electricity hourly pricing (PVPC)" + "iot_class": "cloud_polling" }, "pyload": { + "name": "pyLoad", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "pyLoad" + "iot_class": "local_polling" }, "python_script": { - "config_flow": false, - "iot_class": null, - "name": "Python Scripts" + "name": "Python Scripts", + "integration_type": "hub", + "config_flow": false }, "qbittorrent": { + "name": "qBittorrent", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "qBittorrent" + "iot_class": "local_polling" }, "qingping": { + "name": "Qingping", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Qingping" + "iot_class": "local_push" }, "qld_bushfire": { + "name": "Queensland Bushfire Alert", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Queensland Bushfire Alert" + "iot_class": "cloud_polling" }, "qnap": { "name": "QNAP", "integrations": { "qnap": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "QNAP" }, "qnap_qsw": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "QNAP QSW" @@ -3417,268 +4152,329 @@ } }, "qrcode": { + "name": "QR Code", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "QR Code" + "iot_class": "calculated" }, "quantum_gateway": { + "name": "Quantum Gateway", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Quantum Gateway" + "iot_class": "local_polling" }, "qvr_pro": { + "name": "QVR Pro", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "QVR Pro" + "iot_class": "local_polling" }, "qwikswitch": { + "name": "QwikSwitch QSUSB", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "QwikSwitch QSUSB" + "iot_class": "local_push" }, "rachio": { + "name": "Rachio", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Rachio" + "iot_class": "cloud_push" }, "radarr": { + "name": "Radarr", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Radarr" + "iot_class": "local_polling" }, "radio_browser": { + "name": "Radio Browser", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Radio Browser" + "iot_class": "cloud_polling" }, "radiotherm": { + "name": "Radio Thermostat", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Radio Thermostat" + "iot_class": "local_polling" }, "rainbird": { + "name": "Rain Bird", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Rain Bird" + "iot_class": "local_polling" }, "rainforest_eagle": { + "name": "Rainforest Eagle", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Rainforest Eagle" + "iot_class": "local_polling" }, "rainmachine": { + "name": "RainMachine", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "RainMachine" + "iot_class": "local_polling" }, "random": { + "name": "Random", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Random" + "iot_class": "local_polling" }, - "raspberry": { + "raspberry_pi": { "name": "Raspberry Pi", "integrations": { "rpi_camera": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Raspberry Pi Camera" }, "rpi_power": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" }, "remote_rpi_gpio": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", - "name": "remote_rpi_gpio" + "name": "Raspberry Pi Remote GPIO" } } }, "raspyrfm": { + "name": "RaspyRFM", + "integration_type": "hub", "config_flow": false, - "iot_class": "assumed_state", - "name": "RaspyRFM" + "iot_class": "assumed_state" + }, + "raven_rock_mfg": { + "name": "Raven Rock MFG", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "rdw": { + "name": "RDW", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "RDW" + "iot_class": "cloud_polling" }, "recollect_waste": { + "name": "ReCollect Waste", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "ReCollect Waste" + "iot_class": "cloud_polling" }, "recswitch": { + "name": "Ankuoo REC Switch", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ankuoo REC Switch" + "iot_class": "local_polling" }, "reddit": { + "name": "Reddit", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Reddit" + "iot_class": "cloud_polling" }, "rejseplanen": { + "name": "Rejseplanen", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Rejseplanen" + "iot_class": "cloud_polling" }, "remember_the_milk": { + "name": "Remember The Milk", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Remember The Milk" + "iot_class": "cloud_push" }, "renault": { + "name": "Renault", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Renault" + "iot_class": "cloud_polling" }, "repetier": { + "name": "Repetier-Server", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Repetier-Server" + "iot_class": "local_polling" }, "rest": { + "name": "RESTful", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "RESTful" + "iot_class": "local_polling" }, "rest_command": { + "name": "RESTful Command", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "RESTful Command" + "iot_class": "local_push" + }, + "rexel": { + "name": "Rexel Energeasy Connect", + "integration_type": "virtual", + "supported_by": "overkiz" }, "rflink": { + "name": "RFLink", + "integration_type": "hub", "config_flow": false, - "iot_class": "assumed_state", - "name": "RFLink" + "iot_class": "assumed_state" }, "rfxtrx": { + "name": "RFXCOM RFXtrx", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "RFXCOM RFXtrx" + "iot_class": "local_push" }, "rhasspy": { + "name": "Rhasspy", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Rhasspy" + "iot_class": "local_push" }, "ridwell": { + "name": "Ridwell", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Ridwell" + "iot_class": "cloud_polling" }, "ring": { + "name": "Ring", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Ring" + "iot_class": "cloud_polling" }, "ripple": { + "name": "Ripple", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Ripple" + "iot_class": "cloud_polling" }, "risco": { + "name": "Risco", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Risco" + "iot_class": "local_push" }, "rituals_perfume_genie": { + "name": "Rituals Perfume Genie", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Rituals Perfume Genie" + "iot_class": "cloud_polling" }, "rmvtransport": { + "name": "RMV", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "RMV" + "iot_class": "cloud_polling" + }, + "roborock": { + "name": "Roborock", + "integration_type": "virtual", + "supported_by": "xiaomi_miio" }, "rocketchat": { + "name": "Rocket.Chat", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Rocket.Chat" + "iot_class": "cloud_push" }, "roku": { + "name": "Roku", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "Roku" + "iot_class": "local_polling" }, "roomba": { + "name": "iRobot Roomba and Braava", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "iRobot Roomba and Braava" + "iot_class": "local_push" }, "roon": { + "name": "RoonLabs music player", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "RoonLabs music player" + "iot_class": "local_push" }, "rova": { + "name": "ROVA", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "ROVA" + "iot_class": "cloud_polling" }, "rss_feed_template": { + "name": "RSS Feed Template", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "RSS Feed Template" + "iot_class": "local_push" }, "rtorrent": { + "name": "rTorrent", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "rTorrent" + "iot_class": "local_polling" }, "rtsp_to_webrtc": { + "name": "RTSPtoWebRTC", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "RTSPtoWebRTC" + "iot_class": "local_push" }, "ruckus_unleashed": { + "name": "Ruckus Unleashed", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Ruckus Unleashed" + "iot_class": "local_polling" }, "russound": { "name": "Russound", "integrations": { "russound_rio": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_push", "name": "Russound RIO" }, "russound_rnet": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Russound RNET" } } }, "sabnzbd": { + "name": "SABnzbd", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SABnzbd" + "iot_class": "local_polling" }, "saj": { + "name": "SAJ Solar Inverter", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "SAJ Solar Inverter" + "iot_class": "local_polling" }, "samsung": { "name": "Samsung", "integrations": { "familyhub": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Samsung Family Hub" }, "samsungtv": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Samsung Smart TV" }, "syncthru": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Samsung SyncThru Printer" @@ -3686,333 +4482,437 @@ } }, "satel_integra": { + "name": "Satel Integra", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Satel Integra" + "iot_class": "local_push" }, "schluter": { + "name": "Schluter", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Schluter" + "iot_class": "cloud_polling" }, "scrape": { + "name": "Scrape", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Scrape" + "iot_class": "cloud_polling" + }, + "screenaway": { + "name": "ScreenAway", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "screenlogic": { + "name": "Pentair ScreenLogic", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Pentair ScreenLogic" + "iot_class": "local_polling" }, "scsgate": { + "name": "SCSGate", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "SCSGate" + "iot_class": "local_polling" }, "season": { + "name": "Season", + "integration_type": "service", "config_flow": true, - "iot_class": "local_polling", - "name": "Season" + "iot_class": "local_polling" }, "sendgrid": { + "name": "SendGrid", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "SendGrid" + "iot_class": "cloud_push" }, "sense": { + "name": "Sense", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Sense" + "iot_class": "cloud_polling" }, "senseme": { + "name": "SenseME", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "SenseME" + "iot_class": "local_push" }, "sensibo": { + "name": "Sensibo", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Sensibo" + "iot_class": "cloud_polling" + }, + "sensorblue": { + "name": "SensorBlue", + "integration_type": "virtual", + "supported_by": "thermobeacon" }, "sensorpro": { + "name": "SensorPro", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "SensorPro" + "iot_class": "local_push" }, "sensorpush": { + "name": "SensorPush", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "SensorPush" + "iot_class": "local_push" }, "sentry": { + "name": "Sentry", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Sentry" + "iot_class": "cloud_polling" }, "senz": { + "name": "nVent RAYCHEM SENZ", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "nVent RAYCHEM SENZ" + "iot_class": "cloud_polling" }, "serial": { + "name": "Serial", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Serial" + "iot_class": "local_polling" }, "serial_pm": { + "name": "Serial Particulate Matter", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Serial Particulate Matter" + "iot_class": "local_polling" }, "sesame": { + "name": "Sesame Smart Lock", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Sesame Smart Lock" + "iot_class": "cloud_polling" }, "seven_segments": { + "name": "Seven Segments OCR", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Seven Segments OCR" + "iot_class": "local_polling" }, "seventeentrack": { + "name": "17TRACK", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "17TRACK" + "iot_class": "cloud_polling" }, "sharkiq": { + "name": "Shark IQ", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Shark IQ" + "iot_class": "cloud_polling" }, "shell_command": { + "name": "Shell Command", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Shell Command" + "iot_class": "local_push" }, "shelly": { + "name": "Shelly", + "integration_type": "device", "config_flow": true, - "iot_class": "local_push", - "name": "Shelly" + "iot_class": "local_push" }, "shiftr": { + "name": "shiftr.io", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "shiftr.io" + "iot_class": "cloud_push" }, "shodan": { + "name": "Shodan", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Shodan" + "iot_class": "cloud_polling" }, "shopping_list": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push" }, "sia": { + "name": "SIA Alarm Systems", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "SIA Alarm Systems" + "iot_class": "local_push" }, "sigfox": { + "name": "Sigfox", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Sigfox" + "iot_class": "cloud_polling" }, "sighthound": { + "name": "Sighthound", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Sighthound" + "iot_class": "cloud_polling" }, "signal_messenger": { + "name": "Signal Messenger", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Signal Messenger" + "iot_class": "cloud_push" }, "simplepush": { + "name": "Simplepush", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Simplepush" + "iot_class": "cloud_polling" }, "simplisafe": { + "name": "SimpliSafe", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "SimpliSafe" + "iot_class": "cloud_polling" + }, + "simply_automated": { + "name": "Simply Automated", + "integration_type": "virtual", + "supported_by": "upb" }, "simulated": { + "name": "Simulated", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Simulated" + "iot_class": "local_polling" }, "sinch": { + "name": "Sinch SMS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Sinch SMS" + "iot_class": "cloud_push" }, "sisyphus": { + "name": "Sisyphus", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Sisyphus" + "iot_class": "local_push" }, "sky_hub": { + "name": "Sky Hub", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Sky Hub" + "iot_class": "local_polling" }, "skybeacon": { + "name": "Skybeacon", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Skybeacon" + "iot_class": "local_polling" }, "skybell": { + "name": "SkyBell", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "SkyBell" + "iot_class": "cloud_polling" }, "slack": { + "name": "Slack", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_push", - "name": "Slack" + "iot_class": "cloud_push" }, "sleepiq": { + "name": "SleepIQ", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "SleepIQ" + "iot_class": "cloud_polling" }, "slide": { + "name": "Slide", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Slide" + "iot_class": "cloud_polling" }, "slimproto": { + "name": "SlimProto (Squeezebox players)", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "SlimProto (Squeezebox players)" + "iot_class": "local_push" }, "sma": { + "name": "SMA Solar", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SMA Solar" + "iot_class": "local_polling" }, "smappee": { + "name": "Smappee", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Smappee" + "iot_class": "cloud_polling" + }, + "smart_blinds": { + "name": "Smart Blinds", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, + "smart_home": { + "name": "Smart Home", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "smart_meter_texas": { + "name": "Smart Meter Texas", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Smart Meter Texas" + "iot_class": "cloud_polling" + }, + "smarther": { + "name": "Smarther", + "integration_type": "virtual", + "supported_by": "netatmo" }, "smartthings": { + "name": "SmartThings", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "SmartThings" + "iot_class": "cloud_push" }, "smarttub": { + "name": "SmartTub", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "SmartTub" + "iot_class": "cloud_polling" }, "smarty": { + "name": "Salda Smarty", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Salda Smarty" + "iot_class": "local_polling" }, "smhi": { + "name": "SMHI", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "SMHI" + "iot_class": "cloud_polling" }, "sms": { + "name": "SMS notifications via GSM-modem", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SMS notifications via GSM-modem" + "iot_class": "local_polling" }, "smtp": { + "name": "SMTP", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "SMTP" + "iot_class": "cloud_push" }, "snapcast": { + "name": "Snapcast", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Snapcast" + "iot_class": "local_polling" }, "snips": { + "name": "Snips", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Snips" + "iot_class": "local_push" }, "snmp": { + "name": "SNMP", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "SNMP" + "iot_class": "local_polling" + }, + "snooz": { + "name": "Snooz", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" }, "solaredge": { "name": "SolarEdge", "integrations": { "solaredge": { + "integration_type": "device", "config_flow": true, "iot_class": "cloud_polling", "name": "SolarEdge" }, "solaredge_local": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "SolarEdge Local" } } }, "solarlog": { + "name": "Solar-Log", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Solar-Log" + "iot_class": "local_polling" }, "solax": { + "name": "SolaX Power", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SolaX Power" + "iot_class": "local_polling" }, "soma": { + "name": "Soma Connect", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Soma Connect" + "iot_class": "local_polling" + }, + "somfy": { + "name": "Somfy", + "integration_type": "virtual", + "supported_by": "overkiz" }, "somfy_mylink": { + "name": "Somfy MyLink", + "integration_type": "hub", "config_flow": true, - "iot_class": "assumed_state", - "name": "Somfy MyLink" + "iot_class": "assumed_state" }, "sonarr": { + "name": "Sonarr", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Sonarr" + "iot_class": "local_polling" }, "sonos": { + "name": "Sonos", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Sonos" + "iot_class": "local_push" }, "sony": { "name": "Sony", "integrations": { "braviatv": { + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "Sony Bravia TV" }, "ps4": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Sony PlayStation 4" }, "sony_projector": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Sony Projector" }, "songpal": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Sony Songpal" @@ -4020,273 +4920,309 @@ } }, "soundtouch": { + "name": "Bose SoundTouch", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Bose SoundTouch" + "iot_class": "local_polling" }, "spaceapi": { + "name": "Space API", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Space API" + "iot_class": "cloud_polling" }, "spc": { + "name": "Vanderbilt SPC", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Vanderbilt SPC" + "iot_class": "local_push" }, "speedtestdotnet": { + "name": "Speedtest.net", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Speedtest.net" + "iot_class": "cloud_polling" }, "spider": { + "name": "Itho Daalderop Spider", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Itho Daalderop Spider" + "iot_class": "cloud_polling" }, "splunk": { + "name": "Splunk", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Splunk" + "iot_class": "local_push" }, "spotify": { + "name": "Spotify", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Spotify" + "iot_class": "cloud_polling" }, "sql": { + "name": "SQL", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SQL" + "iot_class": "local_polling" }, "srp_energy": { + "name": "SRP Energy", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "SRP Energy" + "iot_class": "cloud_polling" }, "starline": { + "name": "StarLine", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "StarLine" + "iot_class": "cloud_polling" }, "starlingbank": { + "name": "Starling Bank", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Starling Bank" + "iot_class": "cloud_polling" }, "startca": { + "name": "Start.ca", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Start.ca" + "iot_class": "cloud_polling" }, "statistics": { + "name": "Statistics", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Statistics" + "iot_class": "local_polling" }, "statsd": { + "name": "StatsD", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "StatsD" + "iot_class": "local_push" }, "steam_online": { + "name": "Steam", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Steam" + "iot_class": "cloud_polling" }, "steamist": { + "name": "Steamist", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Steamist" + "iot_class": "local_polling" }, "stiebel_eltron": { + "name": "STIEBEL ELTRON", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "STIEBEL ELTRON" + "iot_class": "local_polling" }, "stookalert": { + "name": "RIVM Stookalert", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "RIVM Stookalert" - }, - "stream": { - "config_flow": false, - "iot_class": "local_push", - "name": "Stream" + "iot_class": "cloud_polling" }, "streamlabswater": { + "name": "StreamLabs", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "StreamLabs" + "iot_class": "cloud_polling" }, "subaru": { + "name": "Subaru", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Subaru" + "iot_class": "cloud_polling" }, "suez_water": { + "name": "Suez Water", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Suez Water" + "iot_class": "cloud_polling" }, "sun": { + "integration_type": "hub", "config_flow": true, "iot_class": "calculated" }, "supervisord": { + "name": "Supervisord", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Supervisord" + "iot_class": "local_polling" }, "supla": { + "name": "Supla", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Supla" + "iot_class": "cloud_polling" }, "surepetcare": { + "name": "Sure Petcare", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Sure Petcare" + "iot_class": "cloud_polling" }, "swiss_hydrological_data": { + "name": "Swiss Hydrological Data", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Swiss Hydrological Data" + "iot_class": "cloud_polling" }, "swiss_public_transport": { + "name": "Swiss public transport", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Swiss public transport" + "iot_class": "cloud_polling" }, "swisscom": { + "name": "Swisscom Internet-Box", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Swisscom Internet-Box" + "iot_class": "local_polling" }, "switchbee": { + "name": "SwitchBee", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "SwitchBee" + "iot_class": "local_polling" }, "switchbot": { + "name": "SwitchBot", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "SwitchBot" + "iot_class": "local_push" }, "switcher_kis": { + "name": "Switcher", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Switcher" + "iot_class": "local_push" }, "switchmate": { + "name": "Switchmate SimplySmart Home", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Switchmate SimplySmart Home" + "iot_class": "local_polling" }, "syncthing": { + "name": "Syncthing", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Syncthing" + "iot_class": "local_polling" }, "synology": { "name": "Synology", "integrations": { "synology_chat": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Synology Chat" }, "synology_dsm": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Synology DSM" }, "synology_srm": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Synology SRM" } } }, "syslog": { + "name": "Syslog", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Syslog" + "iot_class": "local_push" }, "system_bridge": { + "name": "System Bridge", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "System Bridge" - }, - "system_log": { - "config_flow": false, - "iot_class": null, - "name": "System Log" + "iot_class": "local_push" }, "systemmonitor": { + "name": "System Monitor", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "System Monitor" + "iot_class": "local_push" }, "tado": { + "name": "Tado", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Tado" + "iot_class": "cloud_polling" }, "tag": { - "config_flow": false, - "iot_class": null + "integration_type": "hub", + "config_flow": false }, "tailscale": { + "name": "Tailscale", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Tailscale" + "iot_class": "cloud_polling" }, "tank_utility": { + "name": "Tank Utility", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Tank Utility" + "iot_class": "cloud_polling" }, "tankerkoenig": { + "name": "Tankerkoenig", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Tankerkoenig" + "iot_class": "cloud_polling" }, "tapsaff": { + "name": "Taps Aff", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Taps Aff" + "iot_class": "local_polling" }, "tasmota": { + "name": "Tasmota", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Tasmota" + "iot_class": "local_push" }, "tautulli": { + "name": "Tautulli", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Tautulli" + "iot_class": "local_polling" }, "tcp": { + "name": "TCP", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "TCP" + "iot_class": "local_polling" }, "ted5000": { + "name": "The Energy Detective TED5000", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "The Energy Detective TED5000" + "iot_class": "local_polling" }, "telegram": { "name": "Telegram", "integrations": { "telegram": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Telegram" }, "telegram_bot": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Telegram bot" } @@ -4296,46 +5232,53 @@ "name": "Telldus", "integrations": { "tellduslive": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Telldus Live" }, "tellstick": { - "config_flow": false, + "integration_type": "hub", "iot_class": "assumed_state", "name": "TellStick" } } }, "telnet": { + "name": "Telnet", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Telnet" + "iot_class": "local_polling" }, "temper": { + "name": "TEMPer", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "TEMPer" + "iot_class": "local_polling" }, "template": { + "name": "Template", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Template" + "iot_class": "local_push" }, "tensorflow": { + "name": "TensorFlow", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "TensorFlow" + "iot_class": "local_polling" }, "tesla": { "name": "Tesla", "integrations": { "powerwall": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Tesla Powerwall" }, "tesla_wall_connector": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Tesla Wall Connector" @@ -4343,39 +5286,51 @@ } }, "tfiac": { + "name": "Tfiac", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Tfiac" + "iot_class": "local_polling" }, "thermobeacon": { + "name": "ThermoBeacon", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "ThermoBeacon" + "iot_class": "local_push" + }, + "thermoplus": { + "name": "ThermoPlus", + "integration_type": "virtual", + "supported_by": "thermobeacon" }, "thermopro": { + "name": "ThermoPro", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "ThermoPro" + "iot_class": "local_push" }, "thermoworks_smoke": { + "name": "ThermoWorks Smoke", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "ThermoWorks Smoke" + "iot_class": "cloud_polling" }, "thethingsnetwork": { + "name": "The Things Network", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "The Things Network" + "iot_class": "local_push" }, "thingspeak": { + "name": "ThingSpeak", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "ThingSpeak" + "iot_class": "cloud_push" }, "thinkingcleaner": { + "name": "Thinking Cleaner", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Thinking Cleaner" + "iot_class": "local_polling" }, "third_reality": { "name": "Third Reality", @@ -4384,119 +5339,136 @@ ] }, "thomson": { + "name": "Thomson", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Thomson" + "iot_class": "local_polling" }, "tibber": { + "name": "Tibber", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Tibber" + "iot_class": "cloud_polling" }, "tikteck": { + "name": "Tikteck", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Tikteck" + "iot_class": "local_polling" }, "tile": { + "name": "Tile", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Tile" + "iot_class": "cloud_polling" }, "tilt_ble": { + "name": "Tilt Hydrometer BLE", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Tilt Hydrometer BLE" + "iot_class": "local_push" }, "time_date": { + "name": "Time & Date", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Time & Date" + "iot_class": "local_push" }, "tmb": { + "name": "Transports Metropolitans de Barcelona", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Transports Metropolitans de Barcelona" + "iot_class": "local_polling" }, "todoist": { + "name": "Todoist", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Todoist" + "iot_class": "cloud_polling" }, "tolo": { + "name": "TOLO Sauna", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "TOLO Sauna" + "iot_class": "local_polling" }, "tomato": { + "name": "Tomato", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Tomato" + "iot_class": "local_polling" }, "tomorrowio": { + "name": "Tomorrow.io", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Tomorrow.io" + "iot_class": "cloud_polling" }, "toon": { + "name": "Toon", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Toon" + "iot_class": "cloud_push" }, "torque": { + "name": "Torque", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Torque" + "iot_class": "cloud_polling" }, "totalconnect": { + "name": "Total Connect", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Total Connect" + "iot_class": "cloud_polling" }, "touchline": { + "name": "Roth Touchline", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Roth Touchline" + "iot_class": "local_polling" }, "tplink": { + "name": "TP-Link Kasa Smart", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "TP-Link Kasa Smart" + "iot_class": "local_polling" }, "tplink_lte": { + "name": "TP-Link LTE", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "TP-Link LTE" + "iot_class": "local_polling" }, "traccar": { + "name": "Traccar", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Traccar" + "iot_class": "local_polling" }, "tractive": { + "name": "Tractive", + "integration_type": "device", "config_flow": true, - "iot_class": "cloud_push", - "name": "Tractive" - }, - "tradfri": { - "config_flow": true, - "iot_class": "local_polling", - "name": "IKEA TR\u00c5DFRI" + "iot_class": "cloud_push" }, "trafikverket": { "name": "Trafikverket", "integrations": { "trafikverket_ferry": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Trafikverket Ferry" }, "trafikverket_train": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Trafikverket Train" }, "trafikverket_weatherstation": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Trafikverket Weather Station" @@ -4504,95 +5476,113 @@ } }, "transmission": { + "name": "Transmission", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Transmission" + "iot_class": "local_polling" }, "transport_nsw": { + "name": "Transport NSW", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Transport NSW" + "iot_class": "cloud_polling" }, "travisci": { + "name": "Travis-CI", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Travis-CI" + "iot_class": "cloud_polling" }, "trend": { + "name": "Trend", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Trend" + "iot_class": "local_push" }, "tuya": { + "name": "Tuya", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Tuya" + "iot_class": "cloud_push" }, "twentemilieu": { + "name": "Twente Milieu", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Twente Milieu" + "iot_class": "cloud_polling" }, "twilio": { "name": "Twilio", "integrations": { "twilio": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "Twilio" }, "twilio_call": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Twilio Call" }, "twilio_sms": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Twilio SMS" } } }, "twinkly": { + "name": "Twinkly", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Twinkly" + "iot_class": "local_polling" }, "twitch": { + "name": "Twitch", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Twitch" + "iot_class": "cloud_polling" }, "twitter": { + "name": "Twitter", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Twitter" + "iot_class": "cloud_push" }, "u_tec": { "name": "U-tec", - "iot_standards": [ - "zwave" - ] + "integrations": { + "ultraloq": { + "integration_type": "virtual", + "iot_standards": [ + "zwave" + ], + "name": "Ultraloq" + } + } }, "ubiquiti": { "name": "Ubiquiti", "integrations": { "unifi": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "UniFi Network" }, "unifi_direct": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "UniFi AP" }, "unifiled": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "UniFi LED" }, "unifiprotect": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "UniFi Protect" @@ -4600,143 +5590,175 @@ } }, "uk_transport": { + "name": "UK Transport", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "UK Transport" + "iot_class": "cloud_polling" }, "ukraine_alarm": { + "name": "Ukraine Alarm", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Ukraine Alarm" + "iot_class": "cloud_polling" }, "universal": { + "name": "Universal Media Player", + "integration_type": "hub", "config_flow": false, - "iot_class": "calculated", - "name": "Universal Media Player" + "iot_class": "calculated" }, "upb": { + "name": "Universal Powerline Bus (UPB)", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Universal Powerline Bus (UPB)" + "iot_class": "local_push" }, "upc_connect": { + "name": "UPC Connect Box", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "UPC Connect Box" + "iot_class": "local_polling" }, "upcloud": { + "name": "UpCloud", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "UpCloud" + "iot_class": "cloud_polling" }, "upnp": { + "name": "UPnP/IGD", + "integration_type": "device", "config_flow": true, - "iot_class": "local_polling", - "name": "UPnP/IGD" + "iot_class": "local_polling" + }, + "uprise_smart_shades": { + "name": "Uprise Smart Shades", + "integration_type": "virtual", + "supported_by": "motion_blinds" }, "uptime": { + "integration_type": "service", "config_flow": true, "iot_class": "local_push" }, "uptimerobot": { + "name": "UptimeRobot", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "UptimeRobot" + "iot_class": "cloud_polling" }, "usgs_earthquakes_feed": { + "name": "U.S. Geological Survey Earthquake Hazards (USGS)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "U.S. Geological Survey Earthquake Hazards (USGS)" + "iot_class": "cloud_polling" }, "uvc": { + "name": "Ubiquiti UniFi Video", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ubiquiti UniFi Video" + "iot_class": "local_polling" }, "vallox": { + "name": "Vallox", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Vallox" + "iot_class": "local_polling" }, "vasttrafik": { + "name": "V\u00e4sttrafik", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "V\u00e4sttrafik" + "iot_class": "cloud_polling" }, "velbus": { + "name": "Velbus", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Velbus" + "iot_class": "local_push" }, "velux": { + "name": "Velux", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Velux" + "iot_class": "local_polling" }, "venstar": { + "name": "Venstar", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Venstar" + "iot_class": "local_polling" }, "vera": { + "name": "Vera", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Vera" + "iot_class": "local_polling" }, "verisure": { + "name": "Verisure", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Verisure" + "iot_class": "cloud_polling" }, "versasense": { + "name": "VersaSense", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "VersaSense" + "iot_class": "local_polling" }, "version": { + "name": "Version", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Version" + "iot_class": "local_push" }, "vesync": { + "name": "VeSync", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "VeSync" + "iot_class": "cloud_polling" }, "viaggiatreno": { + "name": "Trenitalia ViaggiaTreno", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Trenitalia ViaggiaTreno" + "iot_class": "cloud_polling" }, "vicare": { + "name": "Viessmann ViCare", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Viessmann ViCare" + "iot_class": "cloud_polling" }, "vilfo": { + "name": "Vilfo Router", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Vilfo Router" + "iot_class": "local_polling" }, "vivotek": { + "name": "VIVOTEK", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "VIVOTEK" + "iot_class": "local_polling" }, "vizio": { + "name": "VIZIO SmartCast", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "VIZIO SmartCast" + "iot_class": "local_polling" }, "vlc": { "name": "VideoLAN", "integrations": { "vlc": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "VLC media player" }, "vlc_telnet": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "VLC media player via Telnet" @@ -4744,218 +5766,257 @@ } }, "voicerss": { + "name": "VoiceRSS", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "VoiceRSS" + "iot_class": "cloud_push" }, "volkszaehler": { + "name": "Volkszaehler", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Volkszaehler" + "iot_class": "local_polling" }, "volumio": { + "name": "Volumio", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Volumio" + "iot_class": "local_polling" }, "volvooncall": { + "name": "Volvo On Call", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Volvo On Call" + "iot_class": "cloud_polling" }, "vulcan": { + "name": "Uonet+ Vulcan", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Uonet+ Vulcan" + "iot_class": "cloud_polling" }, "vultr": { + "name": "Vultr", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Vultr" + "iot_class": "cloud_polling" }, "w800rf32": { + "name": "WGL Designs W800RF32", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "WGL Designs W800RF32" + "iot_class": "local_push" }, "wake_on_lan": { + "name": "Wake on LAN", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Wake on LAN" + "iot_class": "local_push" }, "wallbox": { + "name": "Wallbox", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Wallbox" + "iot_class": "cloud_polling" }, "waqi": { + "name": "World Air Quality Index (WAQI)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "World Air Quality Index (WAQI)" + "iot_class": "cloud_polling" }, "waterfurnace": { + "name": "WaterFurnace", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "WaterFurnace" + "iot_class": "cloud_polling" }, "watttime": { + "name": "WattTime", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "WattTime" + "iot_class": "cloud_polling" }, "waze_travel_time": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, "webhook": { - "config_flow": false, - "iot_class": null, - "name": "Webhook" + "name": "Webhook", + "integration_type": "hub", + "config_flow": false }, "wemo": { + "name": "Belkin WeMo", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Belkin WeMo" + "iot_class": "local_push" }, "whirlpool": { + "name": "Whirlpool Sixth Sense", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "Whirlpool Sixth Sense" + "iot_class": "cloud_push" }, "whois": { + "name": "Whois", + "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Whois" + "iot_class": "cloud_polling" }, "wiffi": { + "name": "Wiffi", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Wiffi" + "iot_class": "local_push" }, "wilight": { + "name": "WiLight", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "WiLight" + "iot_class": "local_polling" }, "wirelesstag": { + "name": "Wireless Sensor Tags", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Wireless Sensor Tags" + "iot_class": "cloud_push" }, "withings": { + "name": "Withings", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Withings" + "iot_class": "cloud_polling" }, "wiz": { + "name": "WiZ", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "WiZ" + "iot_class": "local_push" }, "wled": { + "name": "WLED", + "integration_type": "device", "config_flow": true, - "iot_class": "local_push", - "name": "WLED" + "iot_class": "local_push" }, "wolflink": { + "name": "Wolf SmartSet Service", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling", - "name": "Wolf SmartSet Service" + "iot_class": "cloud_polling" }, "workday": { + "name": "Workday", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Workday" + "iot_class": "local_polling" }, "worldclock": { + "name": "Worldclock", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "Worldclock" + "iot_class": "local_push" }, "worldtidesinfo": { + "name": "World Tides", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "World Tides" + "iot_class": "cloud_polling" }, "worxlandroid": { + "name": "Worx Landroid", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Worx Landroid" + "iot_class": "local_polling" }, "ws66i": { + "name": "Soundavo WS66i 6-Zone Amplifier", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Soundavo WS66i 6-Zone Amplifier" + "iot_class": "local_polling" }, "wsdot": { + "name": "Washington State Department of Transportation (WSDOT)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Washington State Department of Transportation (WSDOT)" + "iot_class": "cloud_polling" }, "x10": { + "name": "Heyu X10", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Heyu X10" + "iot_class": "local_polling" }, "xeoma": { + "name": "Xeoma", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Xeoma" + "iot_class": "local_polling" }, "xiaomi": { "name": "Xiaomi", "integrations": { "xiaomi_aqara": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Xiaomi Gateway (Aqara)" }, "xiaomi_ble": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Xiaomi BLE" }, "xiaomi_miio": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", "name": "Xiaomi Miio" }, "xiaomi_tv": { - "config_flow": false, + "integration_type": "hub", "iot_class": "assumed_state", "name": "Xiaomi TV" }, "xiaomi": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Xiaomi" } } }, "xmpp": { + "name": "Jabber (XMPP)", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_push", - "name": "Jabber (XMPP)" + "iot_class": "cloud_push" }, "xs1": { + "name": "EZcontrol XS1", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "EZcontrol XS1" + "iot_class": "local_polling" }, "yale": { "name": "Yale", "integrations": { "august": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push", "name": "August" }, "yale_smart_alarm": { + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", "name": "Yale Smart Living" }, "yalexs_ble": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Yale Access Bluetooth" @@ -4963,25 +6024,27 @@ } }, "yamaha": { + "name": "Yamaha Network Receivers", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Yamaha Network Receivers" + "iot_class": "local_polling" }, "yamaha_musiccast": { + "name": "MusicCast", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "MusicCast" + "iot_class": "local_push" }, "yandex": { "name": "Yandex", "integrations": { "yandex_transport": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_polling", "name": "Yandex Transport" }, "yandextts": { - "config_flow": false, + "integration_type": "hub", "iot_class": "cloud_push", "name": "Yandex TTS" } @@ -4991,86 +6054,95 @@ "name": "Yeelight", "integrations": { "yeelight": { + "integration_type": "hub", "config_flow": true, "iot_class": "local_push", "name": "Yeelight" }, "yeelightsunflower": { - "config_flow": false, + "integration_type": "hub", "iot_class": "local_polling", "name": "Yeelight Sunflower" } } }, "yi": { + "name": "Yi Home Cameras", + "integration_type": "device", "config_flow": false, - "iot_class": "local_polling", - "name": "Yi Home Cameras" + "iot_class": "local_polling" }, "yolink": { + "name": "YoLink", + "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push", - "name": "YoLink" + "iot_class": "cloud_push" }, "youless": { + "name": "YouLess", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "YouLess" + "iot_class": "local_polling" }, "zabbix": { + "name": "Zabbix", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Zabbix" + "iot_class": "local_polling" }, "zamg": { - "config_flow": false, - "iot_class": "cloud_polling", - "name": "Zentralanstalt f\u00fcr Meteorologie und Geodynamik (ZAMG)" + "name": "Zentralanstalt f\u00fcr Meteorologie und Geodynamik (ZAMG)", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" }, "zengge": { + "name": "Zengge", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Zengge" + "iot_class": "local_polling" }, "zerproc": { + "name": "Zerproc", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Zerproc" + "iot_class": "local_polling" }, "zestimate": { + "name": "Zestimate", + "integration_type": "hub", "config_flow": false, - "iot_class": "cloud_polling", - "name": "Zestimate" + "iot_class": "cloud_polling" }, "zha": { + "name": "Zigbee Home Automation", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", - "name": "Zigbee Home Automation" + "iot_class": "local_polling" }, "zhong_hong": { + "name": "ZhongHong", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_push", - "name": "ZhongHong" + "iot_class": "local_push" }, "ziggo_mediabox_xl": { + "name": "Ziggo Mediabox XL", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Ziggo Mediabox XL" + "iot_class": "local_polling" }, "zodiac": { + "name": "Zodiac", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "Zodiac" - }, - "zone": { - "config_flow": false, - "iot_class": null, - "name": "Zone" + "iot_class": "local_polling" }, "zoneminder": { + "name": "ZoneMinder", + "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling", - "name": "ZoneMinder" + "iot_class": "local_polling" }, "zooz": { "name": "Zooz", @@ -5079,107 +6151,95 @@ ] }, "zwave_js": { + "name": "Z-Wave", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Z-Wave" + "iot_class": "local_push" }, "zwave_me": { + "name": "Z-Wave.Me", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "Z-Wave.Me" - } - }, - "hardware": { - "hardkernel": { - "config_flow": false, - "iot_class": null, - "name": "Hardkernel" - }, - "homeassistant_sky_connect": { - "config_flow": false, - "iot_class": null, - "name": "Home Assistant Sky Connect" - }, - "homeassistant_yellow": { - "config_flow": false, - "iot_class": null, - "name": "Home Assistant Yellow" - }, - "raspberry_pi": { - "config_flow": false, - "iot_class": null, - "name": "Raspberry Pi" + "iot_class": "local_push" } }, "helper": { "counter": { - "config_flow": false, - "iot_class": null, - "name": "Counter" + "name": "Counter", + "integration_type": "helper", + "config_flow": false }, "derivative": { + "integration_type": "helper", "config_flow": true, "iot_class": "calculated" }, "group": { + "integration_type": "helper", "config_flow": true, "iot_class": "calculated" }, "input_boolean": { - "config_flow": false, - "iot_class": null + "integration_type": "helper", + "config_flow": false }, "input_button": { - "config_flow": false, - "iot_class": null, - "name": "Input Button" + "name": "Input Button", + "integration_type": "helper", + "config_flow": false }, "input_datetime": { - "config_flow": false, - "iot_class": null + "integration_type": "helper", + "config_flow": false }, "input_number": { - "config_flow": false, - "iot_class": null + "integration_type": "helper", + "config_flow": false }, "input_select": { - "config_flow": false, - "iot_class": null + "integration_type": "helper", + "config_flow": false }, "input_text": { - "config_flow": false, - "iot_class": null + "integration_type": "helper", + "config_flow": false }, "integration": { + "integration_type": "helper", "config_flow": true, "iot_class": "local_push" }, "min_max": { + "integration_type": "helper", "config_flow": true, "iot_class": "local_push" }, "schedule": { - "config_flow": false, - "iot_class": null + "integration_type": "helper", + "config_flow": false }, "switch_as_x": { + "integration_type": "helper", "config_flow": true, "iot_class": "calculated" }, "threshold": { + "integration_type": "helper", "config_flow": true, "iot_class": "local_polling" }, "timer": { - "config_flow": false, - "iot_class": null, - "name": "Timer" + "name": "Timer", + "integration_type": "helper", + "config_flow": false }, "tod": { + "integration_type": "helper", "config_flow": true, "iot_class": "local_push" }, "utility_meter": { + "integration_type": "helper", "config_flow": true, "iot_class": "local_push" } diff --git a/homeassistant/generated/supported_brands.py b/homeassistant/generated/supported_brands.py deleted file mode 100644 index 15f2a580a295e605c511f80408ea0e359074a715..0000000000000000000000000000000000000000 --- a/homeassistant/generated/supported_brands.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Automatically generated by hassfest. - -To update, run python3 -m script.hassfest -""" - -HAS_SUPPORTED_BRANDS = [ - "denonavr", - "hunterdouglas_powerview", - "inkbird", - "motion_blinds", - "netatmo", - "overkiz", - "renault", - "switchbee", - "thermobeacon", - "wemo", - "yalexs_ble", -] diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 18ac7112bebd96f11adf6562e14308af998d39c7..fc0c3ea5fa7a9e7394f594ae9391a3add7ac637f 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -242,14 +242,29 @@ ZEROCONF = { "name": "gateway*", }, ], - "_leap._tcp.local.": [ + "_lookin._tcp.local.": [ { - "domain": "lutron_caseta", + "domain": "lookin", }, ], - "_lookin._tcp.local.": [ + "_lutron._tcp.local.": [ { - "domain": "lookin", + "domain": "lutron_caseta", + "properties": { + "SYSTYPE": "radiora3*", + }, + }, + { + "domain": "lutron_caseta", + "properties": { + "SYSTYPE": "smartbridge*", + }, + }, + { + "domain": "lutron_caseta", + "properties": { + "SYSTYPE": "ra2select*", + }, }, ], "_mediaremotetv._tcp.local.": [ diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index a628cdefff49c31106a56d9b22cf35b94b27c7bf..387d2ad09b077ef6cf625d8b90a4d55ef2238089 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -586,31 +586,46 @@ def sun( before_offset = before_offset or timedelta(0) after_offset = after_offset or timedelta(0) - sunrise_today = get_astral_event_date(hass, SUN_EVENT_SUNRISE, today) - sunset_today = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) - - sunrise = sunrise_today - sunset = sunset_today - if today > dt_util.as_local( - cast(datetime, sunrise_today) - ).date() and SUN_EVENT_SUNRISE in (before, after): - tomorrow = dt_util.as_local(utcnow + timedelta(days=1)).date() - sunrise_tomorrow = get_astral_event_date(hass, SUN_EVENT_SUNRISE, tomorrow) - sunrise = sunrise_tomorrow - - if today > dt_util.as_local( - cast(datetime, sunset_today) - ).date() and SUN_EVENT_SUNSET in (before, after): - tomorrow = dt_util.as_local(utcnow + timedelta(days=1)).date() - sunset_tomorrow = get_astral_event_date(hass, SUN_EVENT_SUNSET, tomorrow) - sunset = sunset_tomorrow - - if sunrise is None and SUN_EVENT_SUNRISE in (before, after): + sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, today) + sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) + + has_sunrise_condition = SUN_EVENT_SUNRISE in (before, after) + has_sunset_condition = SUN_EVENT_SUNSET in (before, after) + + after_sunrise = today > dt_util.as_local(cast(datetime, sunrise)).date() + if after_sunrise and has_sunrise_condition: + tomorrow = today + timedelta(days=1) + sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, tomorrow) + + after_sunset = today > dt_util.as_local(cast(datetime, sunset)).date() + if after_sunset and has_sunset_condition: + tomorrow = today + timedelta(days=1) + sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, tomorrow) + + # Special case: before sunrise OR after sunset + # This will handle the very rare case in the polar region when the sun rises/sets + # but does not set/rise. + # However this entire condition does not handle those full days of darkness or light, + # the following should be used instead: + # + # condition: + # condition: state + # entity_id: sun.sun + # state: 'above_horizon' (or 'below_horizon') + # + if before == SUN_EVENT_SUNRISE and after == SUN_EVENT_SUNSET: + wanted_time_before = cast(datetime, sunrise) + before_offset + condition_trace_update_result(wanted_time_before=wanted_time_before) + wanted_time_after = cast(datetime, sunset) + after_offset + condition_trace_update_result(wanted_time_after=wanted_time_after) + return utcnow < wanted_time_before or utcnow > wanted_time_after + + if sunrise is None and has_sunrise_condition: # There is no sunrise today condition_trace_set_result(False, message="no sunrise today") return False - if sunset is None and SUN_EVENT_SUNSET in (before, after): + if sunset is None and has_sunset_condition: # There is no sunset today condition_trace_set_result(False, message="no sunset today") return False diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f6e77ef0018951913e5c6c9245399fb3c6b843c5..35191d7704245eed70b2994392ef42492dd455c7 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -71,8 +71,6 @@ from homeassistant.const import ( CONF_TARGET, CONF_THEN, CONF_TIMEOUT, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, CONF_UNTIL, CONF_VALUE_TEMPLATE, CONF_VARIABLES, @@ -588,11 +586,6 @@ def temperature_unit(value: Any) -> str: raise vol.Invalid("invalid temperature unit (expected C or F)") -unit_system = vol.All( - vol.Lower, vol.Any(CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL) -) - - def template(value: Any | None) -> template_helper.Template: """Validate a jinja2 template.""" if value is None: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 2f2588367b63aa09344a5f47b0a476e1d3eed7d8..57cfe362231491a22c2315fe592cbd5211780f8b 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -340,6 +340,18 @@ class Entity(ABC): """ return self._attr_capability_attributes + def get_initial_entity_options(self) -> er.EntityOptionsType | None: + """Return initial entity options. + + These will be stored in the entity registry the first time the entity is seen, + and then never updated. + + Implemented by component base class, should not be extended by integrations. + + Note: Not a property to avoid calculating unless needed. + """ + return None + @property def state_attributes(self) -> dict[str, Any] | None: """Return the state attributes. diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 86d5436e2878d757d1ad07bc1495b21b9fa46004..eea0a9943a35de84ad9f034a4a885f7a4dec8f20 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -37,6 +37,7 @@ _EntityT = TypeVar("_EntityT", bound=entity.Entity) async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: """Trigger an update for an entity.""" domain = entity_id.split(".", 1)[0] + entity_comp: EntityComponent[entity.Entity] | None entity_comp = hass.data.get(DATA_INSTANCES, {}).get(domain) if entity_comp is None: diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 81487bbb627b3aba69102506af567272cddf8249..9b8e1985930bc9112eff0316c6a5e30e59712188 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -607,9 +607,10 @@ class EntityPlatform: device_id=device_id, disabled_by=disabled_by, entity_category=entity.entity_category, + get_initial_options=entity.get_initial_entity_options, + has_entity_name=entity.has_entity_name, hidden_by=hidden_by, known_object_ids=self.entities.keys(), - has_entity_name=entity.has_entity_name, original_device_class=entity.device_class, original_icon=entity.icon, original_name=entity.name, diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index e58dde19127bbbe1c3e8ca8d5d8ad21de3b9fd85..77a0b5a0400e585cfd392ab1de7dfc6a8a39dd4b 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -94,6 +94,9 @@ class RegistryEntryHider(StrEnum): USER = "user" +EntityOptionsType = Mapping[str, Mapping[str, Any]] + + @attr.s(slots=True, frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -114,7 +117,7 @@ class RegistryEntry: 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( + options: EntityOptionsType = attr.ib( default=None, converter=attr.converters.default_if_none(factory=dict) # type: ignore[misc] ) # As set by integration @@ -397,6 +400,8 @@ class EntityRegistry: # To disable or hide an entity if it gets created disabled_by: RegistryEntryDisabler | None = None, hidden_by: RegistryEntryHider | None = None, + # Function to generate initial entity options if it gets created + get_initial_options: Callable[[], EntityOptionsType | None] | None = None, # Data that we want entry to have capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, config_entry: ConfigEntry | None | UndefinedType = UNDEFINED, @@ -465,6 +470,8 @@ class EntityRegistry: """Return None if value is UNDEFINED, otherwise return value.""" return None if value is UNDEFINED else value + initial_options = get_initial_options() if get_initial_options else None + entry = RegistryEntry( capabilities=none_if_undefined(capabilities), config_entry_id=none_if_undefined(config_entry_id), @@ -474,6 +481,7 @@ class EntityRegistry: entity_id=entity_id, hidden_by=hidden_by, has_entity_name=none_if_undefined(has_entity_name) or False, + options=initial_options, original_device_class=none_if_undefined(original_device_class), original_icon=none_if_undefined(original_icon), original_name=none_if_undefined(original_name), @@ -590,7 +598,7 @@ class EntityRegistry: supported_features: int | UndefinedType = UNDEFINED, unit_of_measurement: str | None | UndefinedType = UNDEFINED, platform: str | None | UndefinedType = UNDEFINED, - options: Mapping[str, Mapping[str, Any]] | UndefinedType = UNDEFINED, + options: EntityOptionsType | UndefinedType = UNDEFINED, ) -> RegistryEntry: """Private facing update properties method.""" old = self.entities[entity_id] @@ -779,7 +787,7 @@ class EntityRegistry: ) -> RegistryEntry: """Update entity options.""" old = self.entities[entity_id] - new_options: Mapping[str, Mapping[str, Any]] = {**old.options, domain: options} + new_options: EntityOptionsType = {**old.options, domain: options} return self._async_update_entity(entity_id, options=new_options) async def async_load(self) -> None: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 107567c98ce88b7b3b3b5c80bac468c97b447eb8..613b6fb322716a15c3c98368d7f7a873aa010c74 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -806,7 +806,7 @@ def async_track_template( track_template = threaded_listener_factory(async_track_template) -class _TrackTemplateResultInfo: +class TrackTemplateResultInfo: """Handle removal / refresh of tracker.""" def __init__( @@ -1145,7 +1145,7 @@ def async_track_template_result( raise_on_template_error: bool = False, strict: bool = False, has_super_template: bool = False, -) -> _TrackTemplateResultInfo: +) -> TrackTemplateResultInfo: """Add a listener that fires when the result of a template changes. The action will fire with the initial result from the template, and @@ -1184,9 +1184,7 @@ def async_track_template_result( Info object used to unregister the listener, and refresh the template. """ - tracker = _TrackTemplateResultInfo( - hass, track_templates, action, has_super_template - ) + tracker = TrackTemplateResultInfo(hass, track_templates, action, has_super_template) tracker.async_setup(raise_on_template_error, strict=strict) return tracker diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py index 32e08874a63e068b3f8895c51006f3a1937ba047..5545aa09f01e3acc3f1ccbad47b45effa5419acd 100644 --- a/homeassistant/helpers/recorder.py +++ b/homeassistant/helpers/recorder.py @@ -38,7 +38,6 @@ async def async_wait_recorder(hass: HomeAssistant) -> bool: Returns False immediately if the recorder is not enabled. """ - # pylint: disable-next=import-outside-toplevel if DOMAIN not in hass.data: return False db_connected: asyncio.Future[bool] = hass.data[DOMAIN].db_connected diff --git a/homeassistant/helpers/start.py b/homeassistant/helpers/start.py index f6c9a536a235ea62eb2f875d4c8f8f82ba2a9280..fe3bd2b098781a7b944b4b066db9dd45895eedcf 100644 --- a/homeassistant/helpers/start.py +++ b/homeassistant/helpers/start.py @@ -4,21 +4,31 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from typing import Any -from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import ( + CALLBACK_TYPE, + CoreState, + Event, + HassJob, + HomeAssistant, + callback, +) @callback -def async_at_start( +def _async_at_core_state( hass: HomeAssistant, at_start_cb: Callable[[HomeAssistant], Coroutine[Any, Any, None] | None], + event_type: str, + check_state: Callable[[HomeAssistant], bool], ) -> CALLBACK_TYPE: - """Execute something when Home Assistant is started. + """Execute a job at_start_cb when Home Assistant has the wanted state. - Will execute it now if Home Assistant is already started. + The job is executed immediately if Home Assistant is in the wanted state. + Will wait for event specified by event_type if it isn't. """ at_start_job = HassJob(at_start_cb) - if hass.is_running: + if check_state(hass): hass.async_run_hass_job(at_start_job, hass) return lambda: None @@ -36,5 +46,43 @@ def async_at_start( if unsub: unsub() - unsub = hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _matched_event) + unsub = hass.bus.async_listen_once(event_type, _matched_event) return cancel + + +@callback +def async_at_start( + hass: HomeAssistant, + at_start_cb: Callable[[HomeAssistant], Coroutine[Any, Any, None] | None], +) -> CALLBACK_TYPE: + """Execute a job at_start_cb when Home Assistant is starting. + + The job is executed immediately if Home Assistant is already starting or started. + Will wait for EVENT_HOMEASSISTANT_START if it isn't. + """ + + def _is_running(hass: HomeAssistant) -> bool: + return hass.is_running + + return _async_at_core_state( + hass, at_start_cb, EVENT_HOMEASSISTANT_START, _is_running + ) + + +@callback +def async_at_started( + hass: HomeAssistant, + at_start_cb: Callable[[HomeAssistant], Coroutine[Any, Any, None] | None], +) -> CALLBACK_TYPE: + """Execute a job at_start_cb when Home Assistant has started. + + The job is executed immediately if Home Assistant is already started. + Will wait for EVENT_HOMEASSISTANT_STARTED if it isn't. + """ + + def _is_started(hass: HomeAssistant) -> bool: + return hass.state == CoreState.running + + return _async_at_core_state( + hass, at_start_cb, EVENT_HOMEASSISTANT_STARTED, _is_started + ) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 5a4d631a8c8bc130ec001afb3f98c23286ac08e4..dfab80e5223c71454df6c7710120a063bbd8316f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -25,7 +25,7 @@ import weakref from awesomeversion import AwesomeVersion import jinja2 -from jinja2 import pass_context, pass_environment +from jinja2 import pass_context, pass_environment, pass_eval_context from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace import voluptuous as vol @@ -1063,6 +1063,14 @@ def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]: ] +def config_entry_id(hass: HomeAssistant, entity_id: str) -> str | None: + """Get an config entry ID from an entity ID.""" + entity_reg = entity_registry.async_get(hass) + if entity := entity_reg.async_get(entity_id): + return entity.config_entry_id + return None + + def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None: """Get a device ID from an entity ID or device name.""" entity_reg = entity_registry.async_get(hass) @@ -1649,7 +1657,7 @@ def min_max_from_filter(builtin_filter: Any, name: str) -> Any: return pass_environment(wrapper) -def average(*args: Any) -> float: +def average(*args: Any, default: Any = _SENTINEL) -> Any: """ Filter and function to calculate the arithmetic mean of an iterable or of two or more arguments. @@ -1658,13 +1666,23 @@ def average(*args: Any) -> float: if len(args) == 0: raise TypeError("average expected at least 1 argument, got 0") - if len(args) == 1: - if isinstance(args[0], Iterable): - return statistics.fmean(args[0]) - + # If first argument is iterable and more then 1 argument provided but not a named default, + # then use 2nd argument as default. + if isinstance(args[0], Iterable): + average_list = args[0] + if len(args) > 1 and default is _SENTINEL: + default = args[1] + elif len(args) == 1: raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + average_list = args - return statistics.fmean(args) + try: + return statistics.fmean(average_list) + except (TypeError, statistics.StatisticsError): + if default is _SENTINEL: + raise_no_default("average", args) + return default def forgiving_float(value, default=_SENTINEL): @@ -2072,6 +2090,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["device_attr"] = hassfunction(device_attr) self.globals["is_device_attr"] = hassfunction(is_device_attr) + self.globals["config_entry_id"] = hassfunction(config_entry_id) + self.filters["config_entry_id"] = pass_context(self.globals["config_entry_id"]) + self.globals["device_id"] = hassfunction(device_id) self.filters["device_id"] = pass_context(self.globals["device_id"]) @@ -2132,9 +2153,13 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["closest"] = pass_context(hassfunction(closest_filter)) self.globals["distance"] = hassfunction(distance) self.globals["is_state"] = hassfunction(is_state) + self.tests["is_state"] = pass_eval_context(self.globals["is_state"]) self.globals["is_state_attr"] = hassfunction(is_state_attr) + self.tests["is_state_attr"] = pass_eval_context(self.globals["is_state_attr"]) self.globals["state_attr"] = hassfunction(state_attr) + self.filters["state_attr"] = self.globals["state_attr"] self.globals["states"] = AllStates(hass) + self.filters["states"] = self.globals["states"] self.globals["utcnow"] = hassfunction(utcnow) self.globals["now"] = hassfunction(now) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index f2a7948f4f521c5af1c2853e2deecb4c48df9873..d43eecda778f6de00a9fbbf6bf39f98e09857678 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -130,7 +130,9 @@ class Manifest(TypedDict, total=False): name: str disabled: str domain: str - integration_type: Literal["entity", "integration", "hardware", "helper", "system"] + integration_type: Literal[ + "entity", "device", "hardware", "helper", "hub", "service", "system" + ] dependencies: list[str] after_dependencies: list[str] requirements: list[str] @@ -150,7 +152,6 @@ class Manifest(TypedDict, total=False): version: str codeowners: list[str] loggers: list[str] - supported_brands: dict[str, str] def manifest_from_legacy_module(domain: str, module: ModuleType) -> Manifest: @@ -224,7 +225,7 @@ async def async_get_custom_components( async def async_get_config_flows( hass: HomeAssistant, - type_filter: Literal["helper", "integration"] | None = None, + type_filter: Literal["device", "helper", "hub", "service"] | None = None, ) -> set[str]: """Return cached list of config flows.""" # pylint: disable=import-outside-toplevel @@ -263,7 +264,6 @@ async def async_get_integration_descriptions( custom_integrations = await async_get_custom_components(hass) custom_flows: dict[str, Any] = { "integration": {}, - "hardware": {}, "helper": {}, } @@ -272,19 +272,25 @@ async def async_get_integration_descriptions( if integration.integration_type in ("entity", "system"): continue - for integration_type in ("integration", "hardware", "helper"): + for integration_type in ("integration", "helper"): if integration.domain not in core_flows[integration_type]: continue del core_flows[integration_type][integration.domain] if integration.domain in core_flows["translated_name"]: core_flows["translated_name"].remove(integration.domain) + if integration.integration_type == "helper": + integration_key: str = integration.integration_type + else: + integration_key = "integration" + metadata = { "config_flow": integration.config_flow, + "integration_type": integration.integration_type, "iot_class": integration.iot_class, "name": integration.name, } - custom_flows[integration.integration_type][integration.domain] = metadata + custom_flows[integration_key][integration.domain] = metadata return {"core": core_flows, "custom": custom_flows} @@ -599,9 +605,9 @@ class Integration: @property def integration_type( self, - ) -> Literal["entity", "integration", "hardware", "helper", "system"]: + ) -> Literal["entity", "device", "hardware", "helper", "hub", "service", "system"]: """Return the integration type.""" - return self.manifest.get("integration_type", "integration") + return self.manifest.get("integration_type", "hub") @property def mqtt(self) -> list[str] | None: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2793a383050606fd92cf7e14fc1d8c7820348dcf..39158d63d550fd60eed9d92e0e5742ed753b6596 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,32 +4,32 @@ aiodiscover==1.4.13 aiohttp==3.8.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.31.2 +async-upnp-client==0.32.1 async_timeout==4.0.2 atomicwrites-homeassistant==1.4.1 attrs==21.2.0 awesomeversion==22.9.0 bcrypt==3.1.7 -bleak-retry-connector==2.1.3 -bleak==0.18.1 +bleak-retry-connector==2.8.2 +bleak==0.19.1 bluetooth-adapters==0.6.0 bluetooth-auto-recovery==0.3.6 certifi>=2021.5.30 ciso8601==2.2.0 cryptography==38.0.1 -dbus-fast==1.24.0 +dbus-fast==1.61.1 fnvhash==0.1.0 hass-nabucasa==0.56.0 -home-assistant-bluetooth==1.3.0 -home-assistant-frontend==20221010.0 +home-assistant-bluetooth==1.6.0 +home-assistant-frontend==20221102.1 httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 -orjson==3.7.11 +orjson==3.8.1 paho-mqtt==1.6.1 pillow==9.2.0 -pip>=21.0,<22.3 +pip>=21.0,<22.4 psutil-home-assistant==0.0.1 pyserial==3.5 python-slugify==4.0.1 @@ -37,12 +37,12 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.1 scapy==2.4.5 -sqlalchemy==1.4.41 +sqlalchemy==1.4.42 typing-extensions>=4.4.0,<5.0 voluptuous-serialize==2.5.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.39.1 +zeroconf==0.39.4 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -114,9 +114,8 @@ multidict>=6.0.2 # https://github.com/home-assistant/core/pull/68176 authlib<1.0 -# Pin backoff for compatibility until most libraries have been updated -# https://github.com/home-assistant/core/pull/70817 -backoff<2.0 +# Version 2.0 added typing, prevent accidental fallbacks +backoff>=2.0 # Breaking change in version # https://github.com/samuelcolvin/pydantic/issues/4092 diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 494ee04546c9889dfc5f54b18f2f2a011523be2e..3823c0e45bdbacb35d32ac9d2aa2fff951ad0413 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -436,10 +436,12 @@ def color_rgbw_to_rgb(r: int, g: int, b: int, w: int) -> tuple[int, int, int]: def color_rgb_to_rgbww( - r: int, g: int, b: int, min_mireds: int, max_mireds: int + r: int, g: int, b: int, min_kelvin: int, max_kelvin: int ) -> tuple[int, int, int, int, int]: """Convert an rgb color to an rgbww representation.""" # Find the color temperature when both white channels have equal brightness + max_mireds = color_temperature_kelvin_to_mired(min_kelvin) + min_mireds = color_temperature_kelvin_to_mired(max_kelvin) mired_range = max_mireds - min_mireds mired_midpoint = min_mireds + mired_range / 2 color_temp_kelvin = color_temperature_mired_to_kelvin(mired_midpoint) @@ -460,10 +462,12 @@ def color_rgb_to_rgbww( def color_rgbww_to_rgb( - r: int, g: int, b: int, cw: int, ww: int, min_mireds: int, max_mireds: int + r: int, g: int, b: int, cw: int, ww: int, min_kelvin: int, max_kelvin: int ) -> tuple[int, int, int]: """Convert an rgbww color to an rgb representation.""" # Calculate color temperature of the white channels + max_mireds = color_temperature_kelvin_to_mired(min_kelvin) + min_mireds = color_temperature_kelvin_to_mired(max_kelvin) mired_range = max_mireds - min_mireds try: ct_ratio = ww / (cw + ww) @@ -530,9 +534,15 @@ def color_temperature_to_rgb( def color_temperature_to_rgbww( - temperature: int, brightness: int, min_mireds: int, max_mireds: int + temperature: int, brightness: int, min_kelvin: int, max_kelvin: int ) -> tuple[int, int, int, int, int]: - """Convert color temperature in mireds to rgbcw.""" + """Convert color temperature in kelvin to rgbcw. + + Returns a (r, g, b, cw, ww) tuple. + """ + max_mireds = color_temperature_kelvin_to_mired(min_kelvin) + min_mireds = color_temperature_kelvin_to_mired(max_kelvin) + temperature = color_temperature_kelvin_to_mired(temperature) mired_range = max_mireds - min_mireds cold = ((max_mireds - temperature) / mired_range) * brightness warm = brightness - cold @@ -540,22 +550,33 @@ def color_temperature_to_rgbww( def rgbww_to_color_temperature( - rgbww: tuple[int, int, int, int, int], min_mireds: int, max_mireds: int + rgbww: tuple[int, int, int, int, int], min_kelvin: int, max_kelvin: int ) -> tuple[int, int]: - """Convert rgbcw to color temperature in mireds.""" + """Convert rgbcw to color temperature in kelvin. + + Returns a tuple (color_temperature, brightness). + """ _, _, _, cold, warm = rgbww - return while_levels_to_color_temperature(cold, warm, min_mireds, max_mireds) + return _white_levels_to_color_temperature(cold, warm, min_kelvin, max_kelvin) -def while_levels_to_color_temperature( - cold: int, warm: int, min_mireds: int, max_mireds: int +def _white_levels_to_color_temperature( + cold: int, warm: int, min_kelvin: int, max_kelvin: int ) -> tuple[int, int]: - """Convert whites to color temperature in mireds.""" + """Convert whites to color temperature in kelvin. + + Returns a tuple (color_temperature, brightness). + """ + max_mireds = color_temperature_kelvin_to_mired(min_kelvin) + min_mireds = color_temperature_kelvin_to_mired(max_kelvin) brightness = warm / 255 + cold / 255 if brightness == 0: - return (max_mireds, 0) + # Return the warmest color if brightness is 0 + return (min_kelvin, 0) return round( - ((cold / 255 / brightness) * (min_mireds - max_mireds)) + max_mireds + color_temperature_mired_to_kelvin( + ((cold / 255 / brightness) * (min_mireds - max_mireds)) + max_mireds + ) ), min(255, round(brightness * 255)) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 80b322c1a14c8ff7767baf6b913fb32589838199..44e4403d689dd5c48b9e0cd10565797e03eded6d 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -4,7 +4,9 @@ from __future__ import annotations import bisect from contextlib import suppress import datetime as dt +import platform import re +import time from typing import Any import zoneinfo @@ -13,6 +15,7 @@ import ciso8601 DATE_STR_FORMAT = "%Y-%m-%d" UTC = dt.timezone.utc DEFAULT_TIME_ZONE: dt.tzinfo = dt.timezone.utc +CLOCK_MONOTONIC_COARSE = 6 # EPOCHORDINAL is not exposed as a constant # https://github.com/python/cpython/blob/3.10/Lib/zoneinfo/_zoneinfo.py#L12 @@ -461,3 +464,26 @@ def _datetime_ambiguous(dattim: dt.datetime) -> bool: assert dattim.tzinfo is not None opposite_fold = dattim.replace(fold=not dattim.fold) return _datetime_exists(dattim) and dattim.utcoffset() != opposite_fold.utcoffset() + + +def __monotonic_time_coarse() -> float: + """Return a monotonic time in seconds. + + This is the coarse version of time_monotonic, which is faster but less accurate. + + Since many arm64 and 32-bit platforms don't support VDSO with time.monotonic + because of errata, we can't rely on the kernel to provide a fast + monotonic time. + + https://lore.kernel.org/lkml/20170404171826.25030-1-marc.zyngier@arm.com/ + """ + return time.clock_gettime(CLOCK_MONOTONIC_COARSE) + + +monotonic_time_coarse = time.monotonic +with suppress(Exception): + if ( + platform.system() == "Linux" + and abs(time.monotonic() - __monotonic_time_coarse()) < 1 + ): + monotonic_time_coarse = __monotonic_time_coarse diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 6d502ee6e6d860226b1f70256dfea4925b00b9af..aa2782e423f0857ee4ccee659af2ef8d5c214ebe 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -2,45 +2,6 @@ from __future__ import annotations from homeassistant.const import ( - ENERGY_KILO_WATT_HOUR, - ENERGY_MEGA_WATT_HOUR, - ENERGY_WATT_HOUR, - LENGTH_CENTIMETERS, - LENGTH_FEET, - LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - LENGTH_MILLIMETERS, - LENGTH_YARD, - MASS_GRAMS, - MASS_KILOGRAMS, - MASS_MICROGRAMS, - MASS_MILLIGRAMS, - MASS_OUNCES, - MASS_POUNDS, - POWER_KILO_WATT, - POWER_WATT, - PRESSURE_BAR, - PRESSURE_CBAR, - PRESSURE_HPA, - PRESSURE_INHG, - PRESSURE_KPA, - PRESSURE_MBAR, - PRESSURE_MMHG, - PRESSURE_PA, - PRESSURE_PSI, - 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, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_KELVIN, UNIT_NOT_RECOGNIZED_TEMPLATE, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, @@ -48,6 +9,14 @@ from homeassistant.const import ( VOLUME_GALLONS, VOLUME_LITERS, VOLUME_MILLILITERS, + UnitOfEnergy, + UnitOfLength, + UnitOfMass, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolumetricFlux, ) from homeassistant.exceptions import HomeAssistantError @@ -71,6 +40,10 @@ _DAYS_TO_SECS = 24 * _HRS_TO_SECS # 1 day = 24 hours = 86400 seconds _POUND_TO_G = 453.59237 _OUNCE_TO_G = _POUND_TO_G / 16 +# Pressure conversion constants +_STANDARD_GRAVITY = 9.80665 +_MERCURY_DENSITY = 13.5951 + # Volume conversion constants _L_TO_CUBIC_METER = 0.001 # 1 L = 0.001 m³ _ML_TO_CUBIC_METER = 0.001 * _L_TO_CUBIC_METER # 1 mL = 0.001 L @@ -121,26 +94,26 @@ class DistanceConverter(BaseUnitConverter): """Utility to convert distance values.""" UNIT_CLASS = "distance" - NORMALIZED_UNIT = LENGTH_METERS + NORMALIZED_UNIT = UnitOfLength.METERS _UNIT_CONVERSION: dict[str, float] = { - LENGTH_METERS: 1, - LENGTH_MILLIMETERS: 1 / _MM_TO_M, - LENGTH_CENTIMETERS: 1 / _CM_TO_M, - LENGTH_KILOMETERS: 1 / _KM_TO_M, - LENGTH_INCHES: 1 / _IN_TO_M, - LENGTH_FEET: 1 / _FOOT_TO_M, - LENGTH_YARD: 1 / _YARD_TO_M, - LENGTH_MILES: 1 / _MILE_TO_M, + UnitOfLength.METERS: 1, + UnitOfLength.MILLIMETERS: 1 / _MM_TO_M, + UnitOfLength.CENTIMETERS: 1 / _CM_TO_M, + UnitOfLength.KILOMETERS: 1 / _KM_TO_M, + UnitOfLength.INCHES: 1 / _IN_TO_M, + UnitOfLength.FEET: 1 / _FOOT_TO_M, + UnitOfLength.YARDS: 1 / _YARD_TO_M, + UnitOfLength.MILES: 1 / _MILE_TO_M, } VALID_UNITS = { - LENGTH_KILOMETERS, - LENGTH_MILES, - LENGTH_FEET, - LENGTH_METERS, - LENGTH_CENTIMETERS, - LENGTH_MILLIMETERS, - LENGTH_INCHES, - LENGTH_YARD, + UnitOfLength.KILOMETERS, + UnitOfLength.MILES, + UnitOfLength.FEET, + UnitOfLength.METERS, + UnitOfLength.CENTIMETERS, + UnitOfLength.MILLIMETERS, + UnitOfLength.INCHES, + UnitOfLength.YARDS, } @@ -148,16 +121,18 @@ class EnergyConverter(BaseUnitConverter): """Utility to convert energy values.""" UNIT_CLASS = "energy" - NORMALIZED_UNIT = ENERGY_KILO_WATT_HOUR + NORMALIZED_UNIT = UnitOfEnergy.KILO_WATT_HOUR _UNIT_CONVERSION: dict[str, float] = { - ENERGY_WATT_HOUR: 1 * 1000, - ENERGY_KILO_WATT_HOUR: 1, - ENERGY_MEGA_WATT_HOUR: 1 / 1000, + UnitOfEnergy.WATT_HOUR: 1 * 1000, + UnitOfEnergy.KILO_WATT_HOUR: 1, + UnitOfEnergy.MEGA_WATT_HOUR: 1 / 1000, + UnitOfEnergy.GIGA_JOULE: 3.6 / 1000, } VALID_UNITS = { - ENERGY_WATT_HOUR, - ENERGY_KILO_WATT_HOUR, - ENERGY_MEGA_WATT_HOUR, + UnitOfEnergy.WATT_HOUR, + UnitOfEnergy.KILO_WATT_HOUR, + UnitOfEnergy.MEGA_WATT_HOUR, + UnitOfEnergy.GIGA_JOULE, } @@ -165,22 +140,22 @@ class MassConverter(BaseUnitConverter): """Utility to convert mass values.""" UNIT_CLASS = "mass" - NORMALIZED_UNIT = MASS_GRAMS + NORMALIZED_UNIT = UnitOfMass.GRAMS _UNIT_CONVERSION: dict[str, float] = { - MASS_MICROGRAMS: 1 * 1000 * 1000, - MASS_MILLIGRAMS: 1 * 1000, - MASS_GRAMS: 1, - MASS_KILOGRAMS: 1 / 1000, - MASS_OUNCES: 1 / _OUNCE_TO_G, - MASS_POUNDS: 1 / _POUND_TO_G, + UnitOfMass.MICROGRAMS: 1 * 1000 * 1000, + UnitOfMass.MILLIGRAMS: 1 * 1000, + UnitOfMass.GRAMS: 1, + UnitOfMass.KILOGRAMS: 1 / 1000, + UnitOfMass.OUNCES: 1 / _OUNCE_TO_G, + UnitOfMass.POUNDS: 1 / _POUND_TO_G, } VALID_UNITS = { - MASS_GRAMS, - MASS_KILOGRAMS, - MASS_MILLIGRAMS, - MASS_MICROGRAMS, - MASS_OUNCES, - MASS_POUNDS, + UnitOfMass.GRAMS, + UnitOfMass.KILOGRAMS, + UnitOfMass.MILLIGRAMS, + UnitOfMass.MICROGRAMS, + UnitOfMass.OUNCES, + UnitOfMass.POUNDS, } @@ -188,14 +163,14 @@ class PowerConverter(BaseUnitConverter): """Utility to convert power values.""" UNIT_CLASS = "power" - NORMALIZED_UNIT = POWER_WATT + NORMALIZED_UNIT = UnitOfPower.WATT _UNIT_CONVERSION: dict[str, float] = { - POWER_WATT: 1, - POWER_KILO_WATT: 1 / 1000, + UnitOfPower.WATT: 1, + UnitOfPower.KILO_WATT: 1 / 1000, } VALID_UNITS = { - POWER_WATT, - POWER_KILO_WATT, + UnitOfPower.WATT, + UnitOfPower.KILO_WATT, } @@ -203,28 +178,30 @@ class PressureConverter(BaseUnitConverter): """Utility to convert pressure values.""" UNIT_CLASS = "pressure" - NORMALIZED_UNIT = PRESSURE_PA + NORMALIZED_UNIT = UnitOfPressure.PA _UNIT_CONVERSION: dict[str, float] = { - PRESSURE_PA: 1, - PRESSURE_HPA: 1 / 100, - PRESSURE_KPA: 1 / 1000, - PRESSURE_BAR: 1 / 100000, - PRESSURE_CBAR: 1 / 1000, - PRESSURE_MBAR: 1 / 100, - PRESSURE_INHG: 1 / 3386.389, - PRESSURE_PSI: 1 / 6894.757, - PRESSURE_MMHG: 1 / 133.322, + UnitOfPressure.PA: 1, + UnitOfPressure.HPA: 1 / 100, + UnitOfPressure.KPA: 1 / 1000, + UnitOfPressure.BAR: 1 / 100000, + UnitOfPressure.CBAR: 1 / 1000, + UnitOfPressure.MBAR: 1 / 100, + UnitOfPressure.INHG: 1 + / (_IN_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY), + UnitOfPressure.PSI: 1 / 6894.757, + UnitOfPressure.MMHG: 1 + / (_MM_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY), } VALID_UNITS = { - PRESSURE_PA, - PRESSURE_HPA, - PRESSURE_KPA, - PRESSURE_BAR, - PRESSURE_CBAR, - PRESSURE_MBAR, - PRESSURE_INHG, - PRESSURE_PSI, - PRESSURE_MMHG, + UnitOfPressure.PA, + UnitOfPressure.HPA, + UnitOfPressure.KPA, + UnitOfPressure.BAR, + UnitOfPressure.CBAR, + UnitOfPressure.MBAR, + UnitOfPressure.INHG, + UnitOfPressure.PSI, + UnitOfPressure.MMHG, } @@ -232,26 +209,28 @@ class SpeedConverter(BaseUnitConverter): """Utility to convert speed values.""" UNIT_CLASS = "speed" - NORMALIZED_UNIT = SPEED_METERS_PER_SECOND + NORMALIZED_UNIT = UnitOfSpeed.METERS_PER_SECOND _UNIT_CONVERSION: dict[str, float] = { - SPEED_FEET_PER_SECOND: 1 / _FOOT_TO_M, - SPEED_INCHES_PER_DAY: _DAYS_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_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: _DAYS_TO_SECS / _MM_TO_M, + UnitOfVolumetricFlux.INCHES_PER_DAY: _DAYS_TO_SECS / _IN_TO_M, + UnitOfVolumetricFlux.INCHES_PER_HOUR: _HRS_TO_SECS / _IN_TO_M, + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY: _DAYS_TO_SECS / _MM_TO_M, + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR: _HRS_TO_SECS / _MM_TO_M, + UnitOfSpeed.FEET_PER_SECOND: 1 / _FOOT_TO_M, + UnitOfSpeed.KILOMETERS_PER_HOUR: _HRS_TO_SECS / _KM_TO_M, + UnitOfSpeed.KNOTS: _HRS_TO_SECS / _NAUTICAL_MILE_TO_M, + UnitOfSpeed.METERS_PER_SECOND: 1, + UnitOfSpeed.MILES_PER_HOUR: _HRS_TO_SECS / _MILE_TO_M, } VALID_UNITS = { - 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, + UnitOfVolumetricFlux.INCHES_PER_DAY, + UnitOfVolumetricFlux.INCHES_PER_HOUR, + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.KNOTS, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, } @@ -259,16 +238,16 @@ class TemperatureConverter(BaseUnitConverter): """Utility to convert temperature values.""" UNIT_CLASS = "temperature" - NORMALIZED_UNIT = TEMP_CELSIUS + NORMALIZED_UNIT = UnitOfTemperature.CELSIUS VALID_UNITS = { - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_KELVIN, + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + UnitOfTemperature.KELVIN, } _UNIT_CONVERSION = { - TEMP_CELSIUS: 1.0, - TEMP_FAHRENHEIT: 1.8, - TEMP_KELVIN: 1.0, + UnitOfTemperature.CELSIUS: 1.0, + UnitOfTemperature.FAHRENHEIT: 1.8, + UnitOfTemperature.KELVIN: 1.0, } @classmethod @@ -285,28 +264,28 @@ class TemperatureConverter(BaseUnitConverter): if from_unit == to_unit: return value - if from_unit == TEMP_CELSIUS: - if to_unit == TEMP_FAHRENHEIT: + if from_unit == UnitOfTemperature.CELSIUS: + if to_unit == UnitOfTemperature.FAHRENHEIT: return cls._celsius_to_fahrenheit(value) - if to_unit == TEMP_KELVIN: + if to_unit == UnitOfTemperature.KELVIN: return cls._celsius_to_kelvin(value) raise HomeAssistantError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) ) - if from_unit == TEMP_FAHRENHEIT: - if to_unit == TEMP_CELSIUS: + if from_unit == UnitOfTemperature.FAHRENHEIT: + if to_unit == UnitOfTemperature.CELSIUS: return cls._fahrenheit_to_celsius(value) - if to_unit == TEMP_KELVIN: + if to_unit == UnitOfTemperature.KELVIN: return cls._celsius_to_kelvin(cls._fahrenheit_to_celsius(value)) raise HomeAssistantError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) ) - if from_unit == TEMP_KELVIN: - if to_unit == TEMP_CELSIUS: + if from_unit == UnitOfTemperature.KELVIN: + if to_unit == UnitOfTemperature.CELSIUS: return cls._kelvin_to_celsius(value) - if to_unit == TEMP_FAHRENHEIT: + if to_unit == UnitOfTemperature.FAHRENHEIT: return cls._celsius_to_fahrenheit(cls._kelvin_to_celsius(value)) raise HomeAssistantError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 56c588ed1c9a496758ffd55431474c8a5ca5f9de..7e338f8f313cf3967594d16ae4e36970181f41da 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -2,35 +2,27 @@ from __future__ import annotations from numbers import Number +from typing import TYPE_CHECKING, Final + +import voluptuous as vol from homeassistant.const import ( ACCUMULATED_PRECIPITATION, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, LENGTH, - LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_MILES, - LENGTH_MILLIMETERS, MASS, - MASS_GRAMS, - MASS_KILOGRAMS, - MASS_OUNCES, - MASS_POUNDS, PRESSURE, - PRESSURE_PA, - PRESSURE_PSI, - SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, TEMPERATURE, UNIT_NOT_RECOGNIZED_TEMPLATE, VOLUME, - VOLUME_GALLONS, - VOLUME_LITERS, WIND_SPEED, + UnitOfLength, + UnitOfMass, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolume, ) +from homeassistant.helpers.frame import report from .unit_conversion import ( DistanceConverter, @@ -40,9 +32,21 @@ from .unit_conversion import ( VolumeConverter, ) +if TYPE_CHECKING: + from homeassistant.components.sensor import SensorDeviceClass + +_CONF_UNIT_SYSTEM_IMPERIAL: Final = "imperial" +_CONF_UNIT_SYSTEM_METRIC: Final = "metric" +_CONF_UNIT_SYSTEM_US_CUSTOMARY: Final = "us_customary" + LENGTH_UNITS = DistanceConverter.VALID_UNITS -MASS_UNITS: set[str] = {MASS_POUNDS, MASS_OUNCES, MASS_KILOGRAMS, MASS_GRAMS} +MASS_UNITS: set[str] = { + UnitOfMass.POUNDS, + UnitOfMass.OUNCES, + UnitOfMass.KILOGRAMS, + UnitOfMass.GRAMS, +} PRESSURE_UNITS = PressureConverter.VALID_UNITS @@ -50,29 +54,26 @@ VOLUME_UNITS = VolumeConverter.VALID_UNITS WIND_SPEED_UNITS = SpeedConverter.VALID_UNITS -TEMPERATURE_UNITS: set[str] = {TEMP_FAHRENHEIT, TEMP_CELSIUS} +TEMPERATURE_UNITS: set[str] = {UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS} def _is_valid_unit(unit: str, unit_type: str) -> bool: """Check if the unit is valid for it's type.""" if unit_type == LENGTH: - units = LENGTH_UNITS - elif unit_type == ACCUMULATED_PRECIPITATION: - units = LENGTH_UNITS - elif unit_type == WIND_SPEED: - units = WIND_SPEED_UNITS - elif unit_type == TEMPERATURE: - units = TEMPERATURE_UNITS - elif unit_type == MASS: - units = MASS_UNITS - elif unit_type == VOLUME: - units = VOLUME_UNITS - elif unit_type == PRESSURE: - units = PRESSURE_UNITS - else: - return False - - return unit in units + return unit in LENGTH_UNITS + if unit_type == ACCUMULATED_PRECIPITATION: + return unit in LENGTH_UNITS + if unit_type == WIND_SPEED: + return unit in WIND_SPEED_UNITS + if unit_type == TEMPERATURE: + return unit in TEMPERATURE_UNITS + if unit_type == MASS: + return unit in MASS_UNITS + if unit_type == VOLUME: + return unit in VOLUME_UNITS + if unit_type == PRESSURE: + return unit in PRESSURE_UNITS + return False class UnitSystem: @@ -81,13 +82,15 @@ class UnitSystem: def __init__( self, name: str, - temperature: str, + *, + accumulated_precipitation: str, + conversions: dict[tuple[SensorDeviceClass | str | None, str | None], str], length: str, - wind_speed: str, - volume: str, mass: str, pressure: str, - accumulated_precipitation: str, + temperature: str, + volume: str, + wind_speed: str, ) -> None: """Initialize the unit system object.""" errors: str = ", ".join( @@ -107,7 +110,7 @@ class UnitSystem: if errors: raise ValueError(errors) - self.name = name + self._name = name self.accumulated_precipitation_unit = accumulated_precipitation self.temperature_unit = temperature self.length_unit = length @@ -115,11 +118,32 @@ class UnitSystem: self.pressure_unit = pressure self.volume_unit = volume self.wind_speed_unit = wind_speed + self._conversions = conversions + + @property + def name(self) -> str: + """Return the name of the unit system.""" + report( + "accesses the `name` property of the unit system. " + "This is deprecated and will stop working in Home Assistant 2023.1. " + "Please adjust to use instance check instead.", + error_if_core=False, + ) + if self is IMPERIAL_SYSTEM: + # kept for compatibility reasons, with associated warning above + return _CONF_UNIT_SYSTEM_IMPERIAL + return self._name @property def is_metric(self) -> bool: """Determine if this is the metric unit system.""" - return self.name == CONF_UNIT_SYSTEM_METRIC + report( + "accesses the `is_metric` property of the unit system. " + "This is deprecated and will stop working in Home Assistant 2023.1. " + "Please adjust to use instance check instead.", + error_if_core=False, + ) + return self is METRIC_SYSTEM def temperature(self, temperature: float, from_unit: str) -> float: """Convert the given temperature to this unit system.""" @@ -188,25 +212,98 @@ class UnitSystem: WIND_SPEED: self.wind_speed_unit, } + def get_converted_unit( + self, + device_class: SensorDeviceClass | str | None, + original_unit: str | None, + ) -> str | None: + """Return converted unit given a device class or an original unit.""" + return self._conversions.get((device_class, original_unit)) + + +def get_unit_system(key: str) -> UnitSystem: + """Get unit system based on key.""" + if key == _CONF_UNIT_SYSTEM_US_CUSTOMARY: + return US_CUSTOMARY_SYSTEM + if key == _CONF_UNIT_SYSTEM_METRIC: + return METRIC_SYSTEM + raise ValueError(f"`{key}` is not a valid unit system key") + + +def _deprecated_unit_system(value: str) -> str: + """Convert deprecated unit system.""" + + if value == _CONF_UNIT_SYSTEM_IMPERIAL: + # need to add warning in 2023.1 + return _CONF_UNIT_SYSTEM_US_CUSTOMARY + return value + + +validate_unit_system = vol.All( + vol.Lower, + _deprecated_unit_system, + vol.Any(_CONF_UNIT_SYSTEM_METRIC, _CONF_UNIT_SYSTEM_US_CUSTOMARY), +) METRIC_SYSTEM = UnitSystem( - CONF_UNIT_SYSTEM_METRIC, - TEMP_CELSIUS, - LENGTH_KILOMETERS, - SPEED_METERS_PER_SECOND, - VOLUME_LITERS, - MASS_GRAMS, - PRESSURE_PA, - LENGTH_MILLIMETERS, + _CONF_UNIT_SYSTEM_METRIC, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={ + # Convert non-metric distances + ("distance", UnitOfLength.FEET): UnitOfLength.METERS, + ("distance", UnitOfLength.INCHES): UnitOfLength.MILLIMETERS, + ("distance", UnitOfLength.MILES): UnitOfLength.KILOMETERS, + ("distance", UnitOfLength.YARDS): UnitOfLength.METERS, + # Convert non-metric volumes of gas meters + ("gas", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS, + # Convert non-metric speeds except knots to km/h + ("speed", UnitOfSpeed.FEET_PER_SECOND): UnitOfSpeed.KILOMETERS_PER_HOUR, + ("speed", UnitOfSpeed.MILES_PER_HOUR): UnitOfSpeed.KILOMETERS_PER_HOUR, + # Convert non-metric volumes + ("volume", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS, + ("volume", UnitOfVolume.FLUID_OUNCES): UnitOfVolume.MILLILITERS, + ("volume", UnitOfVolume.GALLONS): UnitOfVolume.LITERS, + # Convert non-metric volumes of water meters + ("water", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS, + ("water", UnitOfVolume.GALLONS): UnitOfVolume.LITERS, + }, + length=UnitOfLength.KILOMETERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) -IMPERIAL_SYSTEM = UnitSystem( - CONF_UNIT_SYSTEM_IMPERIAL, - TEMP_FAHRENHEIT, - LENGTH_MILES, - SPEED_MILES_PER_HOUR, - VOLUME_GALLONS, - MASS_POUNDS, - PRESSURE_PSI, - LENGTH_INCHES, +US_CUSTOMARY_SYSTEM = UnitSystem( + _CONF_UNIT_SYSTEM_US_CUSTOMARY, + accumulated_precipitation=UnitOfLength.INCHES, + conversions={ + # Convert non-USCS distances + ("distance", UnitOfLength.CENTIMETERS): UnitOfLength.INCHES, + ("distance", UnitOfLength.KILOMETERS): UnitOfLength.MILES, + ("distance", UnitOfLength.METERS): UnitOfLength.FEET, + ("distance", UnitOfLength.MILLIMETERS): UnitOfLength.INCHES, + # Convert non-USCS volumes of gas meters + ("gas", UnitOfVolume.CUBIC_METERS): UnitOfVolume.CUBIC_FEET, + # Convert non-USCS speeds except knots to mph + ("speed", UnitOfSpeed.METERS_PER_SECOND): UnitOfSpeed.MILES_PER_HOUR, + ("speed", UnitOfSpeed.KILOMETERS_PER_HOUR): UnitOfSpeed.MILES_PER_HOUR, + # Convert non-USCS volumes + ("volume", UnitOfVolume.CUBIC_METERS): UnitOfVolume.CUBIC_FEET, + ("volume", UnitOfVolume.LITERS): UnitOfVolume.GALLONS, + ("volume", UnitOfVolume.MILLILITERS): UnitOfVolume.FLUID_OUNCES, + # Convert non-USCS volumes of water meters + ("water", UnitOfVolume.CUBIC_METERS): UnitOfVolume.CUBIC_FEET, + ("water", UnitOfVolume.LITERS): UnitOfVolume.GALLONS, + }, + length=UnitOfLength.MILES, + mass=UnitOfMass.POUNDS, + pressure=UnitOfPressure.PSI, + temperature=UnitOfTemperature.FAHRENHEIT, + volume=UnitOfVolume.GALLONS, + wind_speed=UnitOfSpeed.MILES_PER_HOUR, ) + +IMPERIAL_SYSTEM = US_CUSTOMARY_SYSTEM +"""IMPERIAL_SYSTEM is deprecated. Please use US_CUSTOMARY_SYSTEM instead.""" diff --git a/mypy.ini b/mypy.ini index 04986db451cd248441556f739aca9fe61d31f786..cd6bc14169d0d108577bac015a1e83bb79abe347 100644 --- a/mypy.ini +++ b/mypy.ini @@ -322,6 +322,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.aqualogic.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.aseko_pool_live.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -392,6 +402,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bayesian.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.binary_sensor.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -402,6 +422,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.blockchain.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.bluetooth.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -522,6 +552,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.clickatell.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.clicksend.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.cpuspeed.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -912,6 +962,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_sheets.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.greeneye_monitor.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1352,6 +1412,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lidarr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lifx.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1832,6 +1902,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.radarr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.recollect_waste.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2052,6 +2132,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.skybell.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.slack.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2082,6 +2172,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.snooz.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sonarr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ssdp.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2243,6 +2353,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tibber.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tile.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2353,6 +2473,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.unifi.update] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.unifiprotect.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2544,6 +2674,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.wled.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.worldclock.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index 45deecfc02ecbe9e768bdbb871503cd78d323198..678773abcb9a3d827a82c2fb591780180d6a54f1 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -267,6 +267,10 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { reason="replaced by EntityCategory enum", constant=re.compile(r"^(ENTITY_CATEGORY_(\w*))|(ENTITY_CATEGORIES)$"), ), + ObsoleteImportMatch( + reason="replaced by local constants", + constant=re.compile(r"^(CONF_UNIT_SYSTEM_(\w*))$"), + ), ], "homeassistant.core": [ ObsoleteImportMatch( @@ -292,6 +296,12 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { constant=re.compile(r"^(distance|pressure|speed|temperature|volume)$"), ), ], + "homeassistant.util.unit_system": [ + ObsoleteImportMatch( + reason="replaced by US_CUSTOMARY_SYSTEM", + constant=re.compile(r"^IMPERIAL_SYSTEM$"), + ), + ], } diff --git a/pyproject.toml b/pyproject.toml index 5ea26ef1976d834214d6e390acad1f4f1636c253..b41ac861aca3ea7ad30c3ebfd7441b1c8b19cb6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2022.10.5" +version = "2022.11.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -36,15 +36,15 @@ dependencies = [ # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.23.0", - "home-assistant-bluetooth==1.3.0", + "home-assistant-bluetooth==1.6.0", "ifaddr==0.1.7", "jinja2==3.1.2", "lru-dict==1.1.8", "PyJWT==2.5.0", # PyJWT has loose dependency. We want the latest one. "cryptography==38.0.1", - "orjson==3.7.11", - "pip>=21.0,<22.3", + "orjson==3.8.1", + "pip>=21.0,<22.4", "python-slugify==4.0.1", "pyyaml==6.0", "requests==2.28.1", diff --git a/requirements.txt b/requirements.txt index 0dfc353823ad813125b8c6cdd63c3f84286184a6..962a9d59dc6d27d7565abf218c43dbff88f19540 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,14 +11,14 @@ bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 httpx==0.23.0 -home-assistant-bluetooth==1.3.0 +home-assistant-bluetooth==1.6.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 PyJWT==2.5.0 cryptography==38.0.1 -orjson==3.7.11 -pip>=21.0,<22.3 +orjson==3.8.1 +pip>=21.0,<22.4 python-slugify==4.0.1 pyyaml==6.0 requests==2.28.1 diff --git a/requirements_all.txt b/requirements_all.txt index c869fcfa433db544667a4e816c8c1c8588725a13..bb2e4308e48e19bfd972b57c7a921aa7e6d670e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -8,7 +8,7 @@ AEMET-OpenData==0.2.1 AIOAladdinConnect==0.1.46 # homeassistant.components.adax -Adax-local==0.1.4 +Adax-local==0.1.5 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -37,7 +37,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.19.15 +PySwitchbot==0.20.2 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -153,7 +153,7 @@ aioecowitt==2022.09.3 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==11.1.1 +aioesphomeapi==11.4.2 # homeassistant.components.flo aioflo==2021.11.0 @@ -162,7 +162,7 @@ aioflo==2021.11.0 aioftp==0.21.3 # homeassistant.components.github -aiogithubapi==22.2.4 +aiogithubapi==22.10.1 # homeassistant.components.guardian aioguardian==2022.07.0 @@ -171,7 +171,7 @@ aioguardian==2022.07.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==2.0.2 +aiohomekit==2.2.13 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -190,10 +190,13 @@ aiokafka==0.7.2 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.8.5 +aiolifx==0.8.6 # homeassistant.components.lifx -aiolifx_effects==0.2.2 +aiolifx_effects==0.3.0 + +# homeassistant.components.lifx +aiolifx_themes==0.2.0 # homeassistant.components.lookin aiolookin==0.1.1 @@ -226,7 +229,7 @@ aioopenexchangerates==0.4.0 aiopulse==0.4.3 # homeassistant.components.hunterdouglas_powerview -aiopvapi==2.0.2 +aiopvapi==2.0.3 # homeassistant.components.pvpc_hourly_pricing aiopvpc==3.0.0 @@ -234,7 +237,7 @@ aiopvpc==3.0.0 # homeassistant.components.lidarr # homeassistant.components.radarr # homeassistant.components.sonarr -aiopyarr==22.9.0 +aiopyarr==22.10.0 # homeassistant.components.qnap_qsw aioqsw==0.2.2 @@ -252,7 +255,7 @@ aiosenseme==0.6.1 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==2.0.2 +aioshelly==4.1.2 # homeassistant.components.skybell aioskybell==22.7.0 @@ -264,7 +267,7 @@ aioslimproto==2.1.1 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.0.0 +aioswitcher==3.1.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -273,7 +276,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==39 +aiounifi==41 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -290,6 +293,9 @@ aioymaps==1.2.2 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airthings_ble +airthings-ble==0.5.2 + # homeassistant.components.airthings airthings_cloud==0.1.0 @@ -309,7 +315,7 @@ ambiclimate==0.2.1 amcrest==1.9.7 # homeassistant.components.androidtv -androidtv[async]==0.0.67 +androidtv[async]==0.0.69 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 @@ -321,7 +327,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.0.0 +apprise==1.1.0 # homeassistant.components.aprs aprslib==0.7.0 @@ -347,7 +353,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.31.2 +async-upnp-client==0.32.1 # homeassistant.components.supla asyncpysupla==0.0.5 @@ -407,13 +413,13 @@ bimmer_connected==0.10.4 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==2.1.3 +bleak-retry-connector==2.8.2 # homeassistant.components.bluetooth -bleak==0.18.1 +bleak==0.19.1 # homeassistant.components.blebox -blebox_uniapi==2.0.2 +blebox_uniapi==2.1.3 # homeassistant.components.blink blinkpy==0.19.2 @@ -459,9 +465,6 @@ brottsplatskartan==0.0.1 # homeassistant.components.brunt brunt==1.2.0 -# homeassistant.components.bsblan -bsblan==0.5.0 - # homeassistant.components.bluetooth_tracker bt_proximity==0.2.1 @@ -537,7 +540,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.24.0 +dbus-fast==1.61.1 # homeassistant.components.debugpy debugpy==1.6.3 @@ -557,10 +560,10 @@ defusedxml==0.7.1 deluge-client==1.7.1 # homeassistant.components.lametric -demetriek==0.2.4 +demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.10.11 +denonavr==0.10.12 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 @@ -684,7 +687,7 @@ fivem-api==0.1.2 fixerio==1.0.0a0 # homeassistant.components.fjaraskupan -fjaraskupan==2.0.0 +fjaraskupan==2.2.0 # homeassistant.components.flipr flipr-api==1.4.2 @@ -706,7 +709,7 @@ forecast_solar==2.2.0 fortiosapi==1.0.5 # homeassistant.components.freebox -freebox-api==0.0.10 +freebox-api==1.0.1 # homeassistant.components.free_mobile freesms==0.2.0 @@ -722,7 +725,7 @@ gTTS==2.2.4 garages-amsterdam==3.0.0 # homeassistant.components.google -gcal-sync==0.10.0 +gcal-sync==2.2.3 # homeassistant.components.geniushub geniushub-client==0.6.30 @@ -765,10 +768,10 @@ goalzero==0.2.1 goodwe==0.2.18 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.11.0 +google-cloud-pubsub==2.13.10 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.12.1 +google-cloud-texttospeech==2.12.3 # homeassistant.components.nest google-nest-sdm==2.0.0 @@ -801,7 +804,7 @@ greenwavereality==0.5.1 gridnet==4.0.0 # homeassistant.components.growatt_server -growattServer==1.2.2 +growattServer==1.2.3 # homeassistant.components.google_sheets gspread==5.5.0 @@ -817,7 +820,7 @@ ha-HAP-python==4.5.2 # homeassistant.components.generic # homeassistant.components.stream -ha-av==10.0.0b5 +ha-av==10.0.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 @@ -865,7 +868,7 @@ hole==0.7.0 holidays==0.16 # homeassistant.components.frontend -home-assistant-frontend==20221010.0 +home-assistant-frontend==20221102.1 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -883,7 +886,7 @@ horimote==0.4.1 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.6.1 +huawei-lte-api==1.6.3 # homeassistant.components.hydrawise hydrawiser==0.2 @@ -895,10 +898,10 @@ hyperion-py==0.7.5 iammeter==0.1.7 # homeassistant.components.iaqualink -iaqualink==0.4.1 +iaqualink==0.5.0 # homeassistant.components.ibeacon -ibeacon_ble==0.7.3 +ibeacon_ble==1.0.1 # homeassistant.components.watson_tts ibm-watson==5.2.2 @@ -946,7 +949,7 @@ iperf3==0.1.11 ismartgate==4.0.4 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.8.1 +jellyfin-apiclient-python==1.9.2 # homeassistant.components.rest jsonpath==0.82 @@ -985,7 +988,7 @@ lakeside==0.12 laundrify_aio==1.1.2 # homeassistant.components.led_ble -led-ble==0.10.1 +led-ble==1.0.0 # homeassistant.components.foscam libpyfoscam==1.0 @@ -1027,7 +1030,7 @@ london-tube-status==0.5 luftdaten==0.7.2 # homeassistant.components.lupusec -lupupy==0.0.24 +lupupy==0.1.9 # homeassistant.components.lw12wifi lw12==0.9.2 @@ -1066,7 +1069,7 @@ messagebird==1.2.0 meteoalertapi==0.3.0 # homeassistant.components.meteo_france -meteofrance-api==1.0.2 +meteofrance-api==1.1.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -1099,7 +1102,7 @@ motioneye-client==0.3.12 mullvad-api==1.0.0 # homeassistant.components.tts -mutagen==1.45.1 +mutagen==1.46.0 # homeassistant.components.mutesync mutesync==0.0.1 @@ -1132,7 +1135,7 @@ nettigo-air-monitor==1.4.2 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.4 +nexia==2.0.5 # homeassistant.components.nextcloud nextcloudmonitor==1.1.0 @@ -1234,6 +1237,9 @@ openwrt-luci-rpc==1.1.11 # homeassistant.components.ubus openwrt-ubus-rpc==0.0.2 +# homeassistant.components.oralb +oralb-ble==0.10.0 + # homeassistant.components.oru oru==0.1.11 @@ -1306,7 +1312,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.21.4 +plugwise==0.25.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1339,7 +1345,7 @@ proxmoxer==1.3.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==5.9.2 +psutil==5.9.3 # homeassistant.components.pulseaudio_loopback pulsectl==20.2.4 @@ -1403,7 +1409,7 @@ pyRFXtrx==0.30.0 pySwitchmate==0.5.1 # homeassistant.components.tibber -pyTibber==0.25.2 +pyTibber==0.25.6 # homeassistant.components.dlink pyW215==0.7.0 @@ -1436,7 +1442,7 @@ pyalmond==0.0.2 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==7.1.1 +pyatmo==7.3.0 # homeassistant.components.atome pyatome==0.1.1 @@ -1472,7 +1478,7 @@ pycarwings2==2.13 pycfdns==1.2.2 # homeassistant.components.channels -pychannels==1.0.0 +pychannels==1.2.3 # homeassistant.components.cast pychromecast==12.1.4 @@ -1499,13 +1505,13 @@ pycsspeechtts==1.0.4 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==2.7.2 +pydaikin==2.8.0 # homeassistant.components.danfoss_air pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==104 +pydeconz==105 # homeassistant.components.delijn pydelijn==1.0.0 @@ -1532,7 +1538,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.eight_sleep -pyeight==0.3.0 +pyeight==0.3.2 # homeassistant.components.emby pyemby==1.8 @@ -1568,7 +1574,7 @@ pyflume==0.6.5 pyfnip==0.2 # homeassistant.components.forked_daapd -pyforked-daapd==0.1.11 +pyforked-daapd==0.1.14 # homeassistant.components.freedompro pyfreedompro==1.1.0 @@ -1625,7 +1631,7 @@ pyintesishome==1.8.0 pyipma==3.0.5 # homeassistant.components.ipp -pyipp==0.11.0 +pyipp==0.12.1 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1637,7 +1643,7 @@ pyirishrail==0.0.2 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.0.7 +pyisy==3.0.8 # homeassistant.components.itach pyitachip2ir==0.0.7 @@ -1676,16 +1682,16 @@ pylaunches==1.3.0 pylgnetcast==0.3.7 # homeassistant.components.forked_daapd -pylibrespot-java==0.1.0 +pylibrespot-java==0.1.1 # homeassistant.components.litejet pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2022.9.6 +pylitterbot==2022.10.2 # homeassistant.components.lutron_caseta -pylutron-caseta==0.15.2 +pylutron-caseta==0.17.1 # homeassistant.components.lutron pylutron==0.2.8 @@ -1739,7 +1745,7 @@ pynetio==0.1.9.1 pynina==0.1.8 # homeassistant.components.nobo_hub -pynobo==1.4.0 +pynobo==1.6.0 # homeassistant.components.nuki pynuki==1.5.2 @@ -1760,7 +1766,7 @@ pynzbgetapi==0.2.0 pyobihai==1.3.2 # homeassistant.components.octoprint -pyoctoprintapi==0.1.8 +pyoctoprintapi==0.1.9 # homeassistant.components.ombi pyombi==0.1.10 @@ -1775,7 +1781,7 @@ pyopnsense==0.2.0 pyoppleio==1.0.5 # homeassistant.components.opentherm_gw -pyotgw==2.0.3 +pyotgw==2.1.1 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp @@ -1783,7 +1789,7 @@ pyotgw==2.0.3 pyotp==2.7.0 # homeassistant.components.overkiz -pyoverkiz==1.5.5 +pyoverkiz==1.5.6 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1884,7 +1890,7 @@ pysignalclirestapi==0.3.18 pyskyqhub==0.1.4 # homeassistant.components.sma -pysma==0.6.12 +pysma==0.7.2 # homeassistant.components.smappee pysmappee==0.2.29 @@ -1904,6 +1910,9 @@ pysml==0.0.8 # homeassistant.components.snmp pysnmplib==5.0.15 +# homeassistant.components.snooz +pysnooz==0.8.2 + # homeassistant.components.soma pysoma==0.0.10 @@ -1911,7 +1920,7 @@ pysoma==0.0.10 pyspcwebgw==0.4.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.0 +pysqueezebox==0.6.1 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 @@ -1940,6 +1949,9 @@ pythinkingcleaner==0.0.3 # homeassistant.components.blockchain python-blockchain-api==0.0.2 +# homeassistant.components.bsblan +python-bsblan==0.5.5 + # homeassistant.components.clementine python-clementine-remote==1.0.1 @@ -2057,13 +2069,13 @@ pytradfri[async]==9.0.0 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.2.0.1 +pytrafikverket==0.2.1 # homeassistant.components.usb pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.2.0 +pyunifiprotect==4.3.4 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2114,7 +2126,7 @@ pyzbar==0.1.7 pyzerproc==0.4.8 # homeassistant.components.qingping -qingping-ble==0.7.0 +qingping-ble==0.8.2 # homeassistant.components.qnap qnapstats==0.4.0 @@ -2138,7 +2150,7 @@ raincloudy==0.0.7 raspyrfm-client==1.2.8 # homeassistant.components.rainmachine -regenmaschine==2022.09.2 +regenmaschine==2022.10.0 # homeassistant.components.renault renault-api==0.1.11 @@ -2229,7 +2241,7 @@ sensorpro-ble==0.5.0 sensorpush-ble==1.5.2 # homeassistant.components.sentry -sentry-sdk==1.9.8 +sentry-sdk==1.10.0 # homeassistant.components.sharkiq sharkiq==0.0.1 @@ -2244,7 +2256,7 @@ shodan==1.28.0 simplehound==0.3 # homeassistant.components.simplepush -simplepush==1.1.4 +simplepush==2.1.1 # homeassistant.components.simplisafe simplisafe-python==2022.07.1 @@ -2265,10 +2277,10 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.16 # homeassistant.components.snapcast -snapcast==2.1.3 +snapcast==2.3.0 # homeassistant.components.sonos -soco==0.28.0 +soco==0.28.1 # homeassistant.components.solaredge_local solaredge-local==0.2.0 @@ -2299,7 +2311,7 @@ spotipy==2.20.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.41 +sqlalchemy==1.4.42 # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -2329,7 +2341,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.5.0 +subarulink==0.6.1 # homeassistant.components.solarlog sunwatcher==0.2.1 @@ -2368,7 +2380,7 @@ tellduslive==0.10.11 temescal==0.5 # homeassistant.components.temper -temperusb==1.5.3 +temperusb==1.6.0 # homeassistant.components.nibe_heatpump tenacity==8.0.1 @@ -2443,7 +2455,7 @@ twitchAPI==2.5.2 uasiren==0.0.1 # homeassistant.components.landisgyr_heat_meter -ultraheat-api==0.4.3 +ultraheat-api==0.5.0 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 @@ -2472,7 +2484,7 @@ vallox-websocket-api==2.12.0 vehicle==0.4.0 # homeassistant.components.velbus -velbus-aio==2022.10.2 +velbus-aio==2022.10.4 # homeassistant.components.venstar venstarcolortouch==0.18 @@ -2548,7 +2560,7 @@ xboxapi==2.0.1 xiaomi-ble==0.10.0 # homeassistant.components.knx -xknx==1.1.0 +xknx==1.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2565,7 +2577,7 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==1.9.2 +yalexs-ble==1.9.5 # homeassistant.components.august yalexs==1.2.6 @@ -2585,14 +2597,17 @@ youless-api==0.16 # homeassistant.components.media_extractor youtube_dl==2021.12.17 +# homeassistant.components.zamg +zamg==0.1.1 + # homeassistant.components.zengge zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.39.1 +zeroconf==0.39.4 # homeassistant.components.zha -zha-quirks==0.0.83 +zha-quirks==0.0.84 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2607,13 +2622,13 @@ zigpy-deconz==0.19.0 zigpy-xbee==0.16.2 # homeassistant.components.zha -zigpy-zigate==0.10.2 +zigpy-zigate==0.10.3 # homeassistant.components.zha zigpy-znp==0.9.1 # homeassistant.components.zha -zigpy==0.51.3 +zigpy==0.51.5 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test.txt b/requirements_test.txt index 9eccb9abb68ee01f62277b4ef3fbb704cb572cd9..b15ceb3b0023ca837f78e5c776d34702d73a5d1e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,14 +7,14 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==2.12.5 +astroid==2.12.12 codecov==2.1.12 coverage==6.4.4 -freezegun==1.2.1 +freezegun==1.2.2 mock-open==1.4.0 -mypy==0.981 +mypy==0.982 pre-commit==2.20.0 -pylint==2.15.0 +pylint==2.15.5 pipdeptree==2.3.1 pytest-aiohttp==0.3.0 pytest-cov==3.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c03bd6ec6050fa8d8586b7da1e72c21cd519e94..a78ce61f213b46e4c09faca6b24528393b25a88e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.2.1 AIOAladdinConnect==0.1.46 # homeassistant.components.adax -Adax-local==0.1.4 +Adax-local==0.1.5 # homeassistant.components.flick_electric PyFlick==0.0.2 @@ -33,7 +33,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.19.15 +PySwitchbot==0.20.2 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 @@ -140,13 +140,13 @@ aioecowitt==2022.09.3 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==11.1.1 +aioesphomeapi==11.4.2 # homeassistant.components.flo aioflo==2021.11.0 # homeassistant.components.github -aiogithubapi==22.2.4 +aiogithubapi==22.10.1 # homeassistant.components.guardian aioguardian==2022.07.0 @@ -155,7 +155,7 @@ aioguardian==2022.07.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==2.0.2 +aiohomekit==2.2.13 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -168,10 +168,13 @@ aiohue==4.5.0 aiokafka==0.7.2 # homeassistant.components.lifx -aiolifx==0.8.5 +aiolifx==0.8.6 # homeassistant.components.lifx -aiolifx_effects==0.2.2 +aiolifx_effects==0.3.0 + +# homeassistant.components.lifx +aiolifx_themes==0.2.0 # homeassistant.components.lookin aiolookin==0.1.1 @@ -201,7 +204,7 @@ aioopenexchangerates==0.4.0 aiopulse==0.4.3 # homeassistant.components.hunterdouglas_powerview -aiopvapi==2.0.2 +aiopvapi==2.0.3 # homeassistant.components.pvpc_hourly_pricing aiopvpc==3.0.0 @@ -209,7 +212,7 @@ aiopvpc==3.0.0 # homeassistant.components.lidarr # homeassistant.components.radarr # homeassistant.components.sonarr -aiopyarr==22.9.0 +aiopyarr==22.10.0 # homeassistant.components.qnap_qsw aioqsw==0.2.2 @@ -227,7 +230,7 @@ aiosenseme==0.6.1 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==2.0.2 +aioshelly==4.1.2 # homeassistant.components.skybell aioskybell==22.7.0 @@ -239,7 +242,7 @@ aioslimproto==2.1.1 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.0.0 +aioswitcher==3.1.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -248,7 +251,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==39 +aiounifi==41 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -265,6 +268,9 @@ aioymaps==1.2.2 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airthings_ble +airthings-ble==0.5.2 + # homeassistant.components.airthings airthings_cloud==0.1.0 @@ -278,7 +284,7 @@ amberelectric==1.0.4 ambiclimate==0.2.1 # homeassistant.components.androidtv -androidtv[async]==0.0.67 +androidtv[async]==0.0.69 # homeassistant.components.anthemav anthemav==1.4.1 @@ -287,7 +293,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.0.0 +apprise==1.1.0 # homeassistant.components.aprs aprslib==0.7.0 @@ -301,7 +307,7 @@ arcam-fmj==0.12.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.31.2 +async-upnp-client==0.32.1 # homeassistant.components.sleepiq asyncsleepiq==1.2.3 @@ -331,13 +337,13 @@ bellows==0.34.2 bimmer_connected==0.10.4 # homeassistant.components.bluetooth -bleak-retry-connector==2.1.3 +bleak-retry-connector==2.8.2 # homeassistant.components.bluetooth -bleak==0.18.1 +bleak==0.19.1 # homeassistant.components.blebox -blebox_uniapi==2.0.2 +blebox_uniapi==2.1.3 # homeassistant.components.blink blinkpy==0.19.2 @@ -366,9 +372,6 @@ brother==2.0.0 # homeassistant.components.brunt brunt==1.2.0 -# homeassistant.components.bsblan -bsblan==0.5.0 - # homeassistant.components.bthome bthome-ble==1.2.2 @@ -417,7 +420,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.24.0 +dbus-fast==1.61.1 # homeassistant.components.debugpy debugpy==1.6.3 @@ -431,10 +434,10 @@ defusedxml==0.7.1 deluge-client==1.7.1 # homeassistant.components.lametric -demetriek==0.2.4 +demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.10.11 +denonavr==0.10.12 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 @@ -506,7 +509,7 @@ file-read-backwards==2.0.0 fivem-api==0.1.2 # homeassistant.components.fjaraskupan -fjaraskupan==2.0.0 +fjaraskupan==2.2.0 # homeassistant.components.flipr flipr-api==1.4.2 @@ -525,7 +528,7 @@ foobot_async==1.0.0 forecast_solar==2.2.0 # homeassistant.components.freebox -freebox-api==0.0.10 +freebox-api==1.0.1 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor @@ -538,7 +541,7 @@ gTTS==2.2.4 garages-amsterdam==3.0.0 # homeassistant.components.google -gcal-sync==0.10.0 +gcal-sync==2.2.3 # homeassistant.components.geocaching geocachingapi==0.2.1 @@ -575,7 +578,7 @@ goalzero==0.2.1 goodwe==0.2.18 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.11.0 +google-cloud-pubsub==2.13.10 # homeassistant.components.nest google-nest-sdm==2.0.0 @@ -596,7 +599,7 @@ greeneye_monitor==3.0.3 gridnet==4.0.0 # homeassistant.components.growatt_server -growattServer==1.2.2 +growattServer==1.2.3 # homeassistant.components.google_sheets gspread==5.5.0 @@ -609,7 +612,7 @@ ha-HAP-python==4.5.2 # homeassistant.components.generic # homeassistant.components.stream -ha-av==10.0.0b5 +ha-av==10.0.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 @@ -645,7 +648,7 @@ hole==0.7.0 holidays==0.16 # homeassistant.components.frontend -home-assistant-frontend==20221010.0 +home-assistant-frontend==20221102.1 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -660,16 +663,16 @@ homepluscontrol==0.0.5 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.6.1 +huawei-lte-api==1.6.3 # homeassistant.components.hyperion hyperion-py==0.7.5 # homeassistant.components.iaqualink -iaqualink==0.4.1 +iaqualink==0.5.0 # homeassistant.components.ibeacon -ibeacon_ble==0.7.3 +ibeacon_ble==1.0.1 # homeassistant.components.ping icmplib==3.0 @@ -699,7 +702,7 @@ iotawattpy==0.1.0 ismartgate==4.0.4 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.8.1 +jellyfin-apiclient-python==1.9.2 # homeassistant.components.rest jsonpath==0.82 @@ -726,7 +729,7 @@ lacrosse-view==0.0.9 laundrify_aio==1.1.2 # homeassistant.components.led_ble -led-ble==0.10.1 +led-ble==1.0.0 # homeassistant.components.foscam libpyfoscam==1.0 @@ -768,7 +771,7 @@ meater-python==0.0.8 melnor-bluetooth==0.0.20 # homeassistant.components.meteo_france -meteofrance-api==1.0.2 +meteofrance-api==1.1.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -801,7 +804,7 @@ motioneye-client==0.3.12 mullvad-api==1.0.0 # homeassistant.components.tts -mutagen==1.45.1 +mutagen==1.46.0 # homeassistant.components.mutesync mutesync==0.0.1 @@ -822,7 +825,7 @@ netmap==0.7.0.2 nettigo-air-monitor==1.4.2 # homeassistant.components.nexia -nexia==2.0.4 +nexia==2.0.5 # homeassistant.components.discord nextcord==2.0.0a8 @@ -879,6 +882,9 @@ open-meteo==0.2.1 # homeassistant.components.openerz openerz-api==0.1.0 +# homeassistant.components.oralb +oralb-ble==0.10.0 + # homeassistant.components.ovo_energy ovoenergy==1.2.0 @@ -933,7 +939,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.21.4 +plugwise==0.25.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1003,7 +1009,7 @@ pyMetno==0.9.0 pyRFXtrx==0.30.0 # homeassistant.components.tibber -pyTibber==0.25.2 +pyTibber==0.25.6 # homeassistant.components.nextbus py_nextbusnext==0.1.5 @@ -1024,7 +1030,7 @@ pyalmond==0.0.2 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==7.1.1 +pyatmo==7.3.0 # homeassistant.components.apple_tv pyatv==0.10.3 @@ -1057,10 +1063,10 @@ pycomfoconnect==0.4 pycoolmasternet-async==0.1.2 # homeassistant.components.daikin -pydaikin==2.7.2 +pydaikin==2.8.0 # homeassistant.components.deconz -pydeconz==104 +pydeconz==105 # homeassistant.components.dexcom pydexcom==0.2.3 @@ -1075,7 +1081,7 @@ pyeconet==0.1.15 pyefergy==22.1.1 # homeassistant.components.eight_sleep -pyeight==0.3.0 +pyeight==0.3.2 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1099,7 +1105,7 @@ pyflic==2.0.3 pyflume==0.6.5 # homeassistant.components.forked_daapd -pyforked-daapd==0.1.11 +pyforked-daapd==0.1.14 # homeassistant.components.freedompro pyfreedompro==1.1.0 @@ -1141,7 +1147,7 @@ pyinsteon==1.2.0 pyipma==3.0.5 # homeassistant.components.ipp -pyipp==0.11.0 +pyipp==0.12.1 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1150,7 +1156,7 @@ pyiqvia==2022.04.0 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.0.7 +pyisy==3.0.8 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 @@ -1177,16 +1183,16 @@ pylast==4.2.1 pylaunches==1.3.0 # homeassistant.components.forked_daapd -pylibrespot-java==0.1.0 +pylibrespot-java==0.1.1 # homeassistant.components.litejet pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2022.9.6 +pylitterbot==2022.10.2 # homeassistant.components.lutron_caseta -pylutron-caseta==0.15.2 +pylutron-caseta==0.17.1 # homeassistant.components.mailgun pymailgunner==1.4 @@ -1225,7 +1231,7 @@ pynetgear==0.10.8 pynina==0.1.8 # homeassistant.components.nobo_hub -pynobo==1.4.0 +pynobo==1.6.0 # homeassistant.components.nuki pynuki==1.5.2 @@ -1243,7 +1249,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.octoprint -pyoctoprintapi==0.1.8 +pyoctoprintapi==0.1.9 # homeassistant.components.openuv pyopenuv==2022.04.0 @@ -1252,7 +1258,7 @@ pyopenuv==2022.04.0 pyopnsense==0.2.0 # homeassistant.components.opentherm_gw -pyotgw==2.0.3 +pyotgw==2.1.1 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp @@ -1260,7 +1266,7 @@ pyotgw==2.0.3 pyotp==2.7.0 # homeassistant.components.overkiz -pyoverkiz==1.5.5 +pyoverkiz==1.5.6 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1325,7 +1331,7 @@ pysiaalarm==3.0.2 pysignalclirestapi==0.3.18 # homeassistant.components.sma -pysma==0.6.12 +pysma==0.7.2 # homeassistant.components.smappee pysmappee==0.2.29 @@ -1339,6 +1345,9 @@ pysmartthings==0.7.6 # homeassistant.components.snmp pysnmplib==5.0.15 +# homeassistant.components.snooz +pysnooz==0.8.2 + # homeassistant.components.soma pysoma==0.0.10 @@ -1346,7 +1355,7 @@ pysoma==0.0.10 pyspcwebgw==0.4.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.0 +pysqueezebox==0.6.1 # homeassistant.components.switchbee pyswitchbee==1.5.5 @@ -1360,6 +1369,9 @@ pytankerkoenig==0.0.6 # homeassistant.components.tautulli pytautulli==21.11.0 +# homeassistant.components.bsblan +python-bsblan==0.5.5 + # homeassistant.components.ecobee python-ecobee-api==0.2.14 @@ -1420,13 +1432,13 @@ pytradfri[async]==9.0.0 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.2.0.1 +pytrafikverket==0.2.1 # homeassistant.components.usb pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.2.0 +pyunifiprotect==4.3.4 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1462,7 +1474,7 @@ pyws66i==1.1 pyzerproc==0.4.8 # homeassistant.components.qingping -qingping-ble==0.7.0 +qingping-ble==0.8.2 # homeassistant.components.rachio rachiopy==1.0.3 @@ -1474,7 +1486,7 @@ radios==0.1.1 radiotherm==2.1.0 # homeassistant.components.rainmachine -regenmaschine==2022.09.2 +regenmaschine==2022.10.0 # homeassistant.components.renault renault-api==0.1.11 @@ -1532,7 +1544,7 @@ sensorpro-ble==0.5.0 sensorpush-ble==1.5.2 # homeassistant.components.sentry -sentry-sdk==1.9.8 +sentry-sdk==1.10.0 # homeassistant.components.sharkiq sharkiq==0.0.1 @@ -1541,7 +1553,7 @@ sharkiq==0.0.1 simplehound==0.3 # homeassistant.components.simplepush -simplepush==1.1.4 +simplepush==2.1.1 # homeassistant.components.simplisafe simplisafe-python==2022.07.1 @@ -1556,7 +1568,7 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.16 # homeassistant.components.sonos -soco==0.28.0 +soco==0.28.1 # homeassistant.components.solaredge solaredge==0.0.2 @@ -1584,7 +1596,7 @@ spotipy==2.20.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.41 +sqlalchemy==1.4.42 # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -1608,7 +1620,7 @@ stookalert==0.1.4 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.5.0 +subarulink==0.6.1 # homeassistant.components.solarlog sunwatcher==0.2.1 @@ -1680,7 +1692,7 @@ twitchAPI==2.5.2 uasiren==0.0.1 # homeassistant.components.landisgyr_heat_meter -ultraheat-api==0.4.3 +ultraheat-api==0.5.0 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 @@ -1706,7 +1718,7 @@ vallox-websocket-api==2.12.0 vehicle==0.4.0 # homeassistant.components.velbus -velbus-aio==2022.10.2 +velbus-aio==2022.10.4 # homeassistant.components.venstar venstarcolortouch==0.18 @@ -1761,7 +1773,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.10.0 # homeassistant.components.knx -xknx==1.1.0 +xknx==1.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -1775,7 +1787,7 @@ xmltodict==0.13.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==1.9.2 +yalexs-ble==1.9.5 # homeassistant.components.august yalexs==1.2.6 @@ -1789,11 +1801,14 @@ yolink-api==0.1.0 # homeassistant.components.youless youless-api==0.16 +# homeassistant.components.zamg +zamg==0.1.1 + # homeassistant.components.zeroconf -zeroconf==0.39.1 +zeroconf==0.39.4 # homeassistant.components.zha -zha-quirks==0.0.83 +zha-quirks==0.0.84 # homeassistant.components.zha zigpy-deconz==0.19.0 @@ -1802,13 +1817,13 @@ zigpy-deconz==0.19.0 zigpy-xbee==0.16.2 # homeassistant.components.zha -zigpy-zigate==0.10.2 +zigpy-zigate==0.10.3 # homeassistant.components.zha zigpy-znp==0.9.1 # homeassistant.components.zha -zigpy==0.51.3 +zigpy==0.51.5 # homeassistant.components.zwave_js zwave-js-server-python==0.43.0 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 1bb2e0a70e492ff0d9e49312f99759ec24603370..ec6edeeea66d8e27b8dc6141ba58d23245d81796 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,7 +1,7 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit bandit==1.7.4 -black==22.8.0 +black==22.10.0 codespell==2.1.0 flake8-comprehensions==3.10.0 flake8-docstrings==1.6.0 @@ -12,5 +12,5 @@ mccabe==0.6.1 pycodestyle==2.8.0 pydocstyle==6.1.1 pyflakes==2.4.0 -pyupgrade==2.38.0 -yamllint==1.27.1 +pyupgrade==3.1.0 +yamllint==1.28.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 3c2f92cf6493036957ecd8251e346882e47c42f9..bbc970f91785e8efde2155de4ea069fde4aa2812 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -124,9 +124,8 @@ multidict>=6.0.2 # https://github.com/home-assistant/core/pull/68176 authlib<1.0 -# Pin backoff for compatibility until most libraries have been updated -# https://github.com/home-assistant/core/pull/70817 -backoff<2.0 +# Version 2.0 added typing, prevent accidental fallbacks +backoff>=2.0 # Breaking change in version # https://github.com/samuelcolvin/pydantic/issues/4092 diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 7fb6ad2d8d01a8cdc3b7150dba4149a53ce06d5c..2725296911882a9c71c5327ca3f9979b021409d4 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -20,7 +20,6 @@ from . import ( requirements, services, ssdp, - supported_brands, translations, usb, zeroconf, @@ -39,7 +38,6 @@ INTEGRATION_PLUGINS = [ requirements, services, ssdp, - supported_brands, translations, usb, zeroconf, diff --git a/script/hassfest/codeowners.py b/script/hassfest/codeowners.py index 5511bc8a518dfcfa3537c464c7e6327c718194ae..0cc580121627ca79c6d87c201fa7e071760bffb9 100644 --- a/script/hassfest/codeowners.py +++ b/script/hassfest/codeowners.py @@ -25,6 +25,8 @@ build.json @home-assistant/supervisor # Other code /homeassistant/scripts/check_config.py @kellerza +/homeassistant/const.py @epenet +/homeassistant/util/ @epenet # Integrations """.strip() @@ -47,7 +49,10 @@ def generate_and_validate(integrations: dict[str, Integration], config: Config): for domain in sorted(integrations): integration = integrations[domain] - if not integration.manifest: + if ( + not integration.manifest + or integration.manifest.get("integration_type") == "virtual" + ): continue codeowners = integration.manifest["codeowners"] diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 81cc5fae829596618a016855a8212d7129e64dc6..9cebb37d3717bbfde6cbb341ecf73207d4fa223e 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -86,7 +86,10 @@ def _generate_and_validate(integrations: dict[str, Integration], config: Config) _validate_integration(config, integration) - domains[integration.integration_type].append(domain) + if integration.integration_type == "helper": + domains["helper"].append(domain) + else: + domains["integration"].append(domain) return black.format_str(BASE.format(to_string(domains)), mode=black.Mode()) @@ -101,11 +104,23 @@ def _populate_brand_integrations( brand_metadata.setdefault("integrations", {}) for domain in sub_integrations: integration = integrations.get(domain) - if not integration or integration.integration_type in ("entity", "system"): + if not integration or integration.integration_type in ( + "entity", + "hardware", + "system", + ): continue - metadata = {} - metadata["config_flow"] = integration.config_flow - metadata["iot_class"] = integration.iot_class + metadata = { + "integration_type": integration.integration_type, + } + if integration.config_flow: + metadata["config_flow"] = integration.config_flow + if integration.iot_class: + metadata["iot_class"] = integration.iot_class + if integration.supported_by: + metadata["supported_by"] = integration.supported_by + if integration.iot_standards: + metadata["iot_standards"] = integration.iot_standards if integration.translated_name: integration_data["translated_name"].add(domain) else: @@ -120,7 +135,6 @@ def _generate_integrations( result = { "integration": {}, - "hardware": {}, "helper": {}, "translated_name": set(), } @@ -165,15 +179,30 @@ def _generate_integrations( result["integration"][domain] = metadata else: # integration integration = integrations[domain] - if integration.integration_type in ("entity", "system"): + if integration.integration_type in ("entity", "system", "hardware"): continue - metadata["config_flow"] = integration.config_flow - metadata["iot_class"] = integration.iot_class + if integration.translated_name: result["translated_name"].add(domain) else: metadata["name"] = integration.name - result[integration.integration_type][domain] = metadata + + metadata["integration_type"] = integration.integration_type + + if integration.integration_type == "virtual": + if integration.supported_by: + metadata["supported_by"] = integration.supported_by + if integration.iot_standards: + metadata["iot_standards"] = integration.iot_standards + else: + metadata["config_flow"] = integration.config_flow + if integration.iot_class: + metadata["iot_class"] = integration.iot_class + + if integration.integration_type == "helper": + result["helper"][domain] = metadata + else: + result["integration"][domain] = metadata return json.dumps( result | {"translated_name": sorted(result["translated_name"])}, indent=2 diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index de227cbb59ca11ec0aea5ae41359ebf1098bef1a..874fb0698182857b58449e3597e553fc5bf91156 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -2,6 +2,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any from urllib.parse import urlparse from awesomeversion import ( @@ -158,12 +159,20 @@ def verify_wildcard(value: str): return value -MANIFEST_SCHEMA = vol.Schema( +INTEGRATION_MANIFEST_SCHEMA = vol.Schema( { vol.Required("domain"): str, vol.Required("name"): str, - vol.Optional("integration_type"): vol.In( - ["entity", "hardware", "helper", "system"] + vol.Optional("integration_type", default="hub"): vol.In( + [ + "device", + "entity", + "hardware", + "helper", + "hub", + "service", + "system", + ] ), vol.Optional("config_flow"): bool, vol.Optional("mqtt"): [str], @@ -246,14 +255,32 @@ MANIFEST_SCHEMA = vol.Schema( vol.Optional("loggers"): [str], vol.Optional("disabled"): str, vol.Optional("iot_class"): vol.In(SUPPORTED_IOT_CLASSES), - vol.Optional("supported_brands"): vol.Schema({str: str}), } ) -CUSTOM_INTEGRATION_MANIFEST_SCHEMA = MANIFEST_SCHEMA.extend( +VIRTUAL_INTEGRATION_MANIFEST_SCHEMA = vol.Schema( + { + vol.Required("domain"): str, + vol.Required("name"): str, + vol.Required("integration_type"): "virtual", + vol.Exclusive("iot_standards", "virtual_integration"): [ + vol.Any("homekit", "zigbee", "zwave") + ], + vol.Exclusive("supported_by", "virtual_integration"): str, + } +) + + +def manifest_schema(value: dict[str, Any]) -> vol.Schema: + """Validate integration manifest.""" + if value.get("integration_type") == "virtual": + return VIRTUAL_INTEGRATION_MANIFEST_SCHEMA(value) + return INTEGRATION_MANIFEST_SCHEMA(value) + + +CUSTOM_INTEGRATION_MANIFEST_SCHEMA = INTEGRATION_MANIFEST_SCHEMA.extend( { vol.Optional("version"): vol.All(str, verify_version), - vol.Remove("supported_brands"): dict, } ) @@ -276,7 +303,7 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No try: if integration.core: - MANIFEST_SCHEMA(integration.manifest) + manifest_schema(integration.manifest) else: CUSTOM_INTEGRATION_MANIFEST_SCHEMA(integration.manifest) except vol.Invalid as err: @@ -304,15 +331,19 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No if ( integration.manifest["domain"] not in NO_IOT_CLASS and "iot_class" not in integration.manifest + and integration.manifest.get("integration_type") != "virtual" ): integration.add_error("manifest", "Domain is missing an IoT Class") - for domain, _name in integration.manifest.get("supported_brands", {}).items(): - if (core_components_dir / domain).exists(): - integration.add_warning( - "manifest", - f"Supported brand domain {domain} collides with built-in core integration", - ) + if ( + integration.manifest.get("integration_type") == "virtual" + and (supported_by := integration.manifest.get("supported_by")) + and not (core_components_dir / supported_by).exists() + ): + integration.add_error( + "manifest", + "Virtual integration points to non-existing supported_by integration", + ) if not integration.core: validate_version(integration) diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 6a7ab64c14f36ac999160dc5b06eae461e2cec22..65d3b1144e8cc4d852ceb879dfc48b68c4e3f22c 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -109,11 +109,12 @@ class Integration: continue init = fil / "__init__.py" - if not init.exists(): + manifest = fil / "manifest.json" + if not init.exists() and not manifest.exists(): print( - f"Warning: {init} missing, skipping directory. " - "If this is your development environment, " - "you can safely delete this folder." + f"Warning: {init} and manifest.json missing, " + "skipping directory. If this is your development " + "environment, you can safely delete this folder." ) continue @@ -170,20 +171,25 @@ class Integration: return self.manifest.get("dependencies", []) @property - def supported_brands(self) -> dict[str]: - """Return dict of supported brands.""" - return self.manifest.get("supported_brands", {}) + def supported_by(self) -> str: + """Return the integration supported by this virtual integration.""" + return self.manifest.get("supported_by", {}) @property def integration_type(self) -> str: """Get integration_type.""" - return self.manifest.get("integration_type", "integration") + return self.manifest.get("integration_type", "hub") @property def iot_class(self) -> str | None: """Return the integration IoT Class.""" return self.manifest.get("iot_class") + @property + def iot_standards(self) -> list[str]: + """Return the IoT standard supported by this virtual integration.""" + return self.manifest.get("iot_standards", []) + def add_error(self, *args: Any, **kwargs: Any) -> None: """Add an error.""" self.errors.append(Error(*args, **kwargs)) diff --git a/script/hassfest/supported_brands.py b/script/hassfest/supported_brands.py deleted file mode 100644 index 4ac2feb40324dba666d36eb8b1cd887340a339ea..0000000000000000000000000000000000000000 --- a/script/hassfest/supported_brands.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Generate supported_brands data.""" -from __future__ import annotations - -import black - -from .model import Config, Integration -from .serializer import to_string - -BASE = """ -\"\"\"Automatically generated by hassfest. - -To update, run python3 -m script.hassfest -\"\"\" - -HAS_SUPPORTED_BRANDS = {} -""".strip() - - -def generate_and_validate(integrations: dict[str, Integration], config: Config) -> str: - """Validate and generate supported_brands data.""" - - brands = [ - domain - for domain, integration in sorted(integrations.items()) - if integration.supported_brands - ] - - return black.format_str(BASE.format(to_string(brands)), mode=black.Mode()) - - -def validate(integrations: dict[str, Integration], config: Config) -> None: - """Validate supported_brands data.""" - supported_brands_path = config.root / "homeassistant/generated/supported_brands.py" - config.cache["supported_brands"] = content = generate_and_validate( - integrations, config - ) - - if config.specific_integrations: - return - - if supported_brands_path.read_text(encoding="utf-8") != content: - config.add_error( - "supported_brands", - "File supported_brands.py is not up to date. Run python3 -m script.hassfest", - fixable=True, - ) - - -def generate(integrations: dict[str, Integration], config: Config): - """Generate supported_brands data.""" - supported_brands_path = config.root / "homeassistant/generated/supported_brands.py" - supported_brands_path.write_text( - f"{config.cache['supported_brands']}", encoding="utf-8" - ) diff --git a/script/pip_check b/script/pip_check index ae780b07d60d644084f42c5de50af18691cae94e..9ed327b54f4228c2354a85a7b72df3586bcd114a 100755 --- a/script/pip_check +++ b/script/pip_check @@ -3,7 +3,7 @@ PIP_CACHE=$1 # Number of existing dependency conflicts # Update if a PR resolve one! -DEPENDENCY_CONFLICTS=4 +DEPENDENCY_CONFLICTS=3 PIP_CHECK=$(pip check --cache-dir=$PIP_CACHE) LINE_COUNT=$(echo "$PIP_CHECK" | wc -l) diff --git a/script/version_bump.py b/script/version_bump.py index f7dc37b5e22dcae0d7da7663baf0ba471dcbd1f7..4a38adbd677afc4bb5f30e27242660778d8e6b13 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -116,7 +116,7 @@ def write_version(version): "PATCH_VERSION: Final = .*\n", f'PATCH_VERSION: Final = "{patch}"\n', content ) - with open("homeassistant/const.py", "wt") as fil: + with open("homeassistant/const.py", "w") as fil: fil.write(content) diff --git a/tests/common.py b/tests/common.py index cc2bc45481023af80884059af3c3db089d3004a7..14f3cdd47c2fd9684d3420ae5e68d59438a8754c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -20,6 +20,7 @@ from typing import Any from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 +import voluptuous as vol from homeassistant import auth, config_entries, core as ha, loader from homeassistant.auth import ( @@ -42,7 +43,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import BLOCK_LOG_TIMEOUT, HomeAssistant +from homeassistant.core import BLOCK_LOG_TIMEOUT, HomeAssistant, ServiceCall, State from homeassistant.helpers import ( area_registry, device_registry, @@ -57,6 +58,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import setup_component from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as date_util @@ -328,7 +330,9 @@ async def async_test_home_assistant(loop, load_registries=True): return hass -def async_mock_service(hass, domain, service, schema=None): +def async_mock_service( + hass: HomeAssistant, domain: str, service: str, schema: vol.Schema | None = None +) -> list[ServiceCall]: """Set up a fake service & return a calls log list to this service.""" calls = [] @@ -417,18 +421,20 @@ def get_fixture_path(filename: str, integration: str | None = None) -> pathlib.P if integration is None: return pathlib.Path(__file__).parent.joinpath("fixtures", filename) - else: - return pathlib.Path(__file__).parent.joinpath( - "components", integration, "fixtures", filename - ) + + return pathlib.Path(__file__).parent.joinpath( + "components", integration, "fixtures", filename + ) -def load_fixture(filename, integration=None): +def load_fixture(filename: str, integration: str | None = None) -> str: """Load a fixture.""" return get_fixture_path(filename, integration).read_text() -def mock_state_change_event(hass, new_state, old_state=None): +def mock_state_change_event( + hass: HomeAssistant, new_state: State, old_state: State | None = None +) -> None: """Mock state change envent.""" event_data = {"entity_id": new_state.entity_id, "new_state": new_state} @@ -439,7 +445,7 @@ def mock_state_change_event(hass, new_state, old_state=None): @ha.callback -def mock_component(hass, component): +def mock_component(hass: HomeAssistant, component: str) -> None: """Mock a component is setup.""" if component in hass.config.components: AssertionError(f"Integration {component} is already setup") @@ -447,7 +453,10 @@ def mock_component(hass, component): hass.config.components.add(component) -def mock_registry(hass, mock_entries=None): +def mock_registry( + hass: HomeAssistant, + mock_entries: dict[str, entity_registry.RegistryEntry] | None = None, +) -> entity_registry.EntityRegistry: """Mock the Entity Registry.""" registry = entity_registry.EntityRegistry(hass) if mock_entries is None: @@ -460,7 +469,9 @@ def mock_registry(hass, mock_entries=None): return registry -def mock_area_registry(hass, mock_entries=None): +def mock_area_registry( + hass: HomeAssistant, mock_entries: dict[str, area_registry.AreaEntry] | None = None +) -> area_registry.AreaRegistry: """Mock the Area Registry.""" registry = area_registry.AreaRegistry(hass) registry.areas = mock_entries or OrderedDict() @@ -469,7 +480,10 @@ def mock_area_registry(hass, mock_entries=None): return registry -def mock_device_registry(hass, mock_entries=None): +def mock_device_registry( + hass: HomeAssistant, + mock_entries: dict[str, device_registry.DeviceEntry] | None = None, +) -> device_registry.DeviceRegistry: """Mock the Device Registry.""" registry = device_registry.DeviceRegistry(hass) registry.devices = device_registry.DeviceRegistryItems() @@ -545,7 +559,9 @@ class MockUser(auth_models.User): self._permissions = auth_permissions.PolicyPermissions(policy, self.perm_lookup) -async def register_auth_provider(hass, config): +async def register_auth_provider( + hass: HomeAssistant, config: ConfigType +) -> auth_providers.AuthProvider: """Register an auth provider.""" provider = await auth_providers.auth_provider_from_config( hass, hass.auth._store, config @@ -909,17 +925,15 @@ def assert_setup_component(count, domain=None): SetupRecorderInstanceT = Callable[..., Awaitable[recorder.Recorder]] -def init_recorder_component(hass, add_config=None): +def init_recorder_component(hass, add_config=None, db_url="sqlite://"): """Initialize the recorder.""" config = dict(add_config) if add_config else {} if recorder.CONF_DB_URL not in config: - config[recorder.CONF_DB_URL] = "sqlite://" # In memory DB + config[recorder.CONF_DB_URL] = db_url if recorder.CONF_COMMIT_INTERVAL not in config: config[recorder.CONF_COMMIT_INTERVAL] = 0 - with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), patch( - "homeassistant.components.recorder.migration.migrate_schema" - ): + with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True): if recorder.DOMAIN not in hass.data: recorder_helper.async_initialize_recorder(hass) assert setup_component(hass, recorder.DOMAIN, {recorder.DOMAIN: config}) diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 8612a80598046d7dd129caacb667751e76a78fb0..55052b5ca3e6a45fc6df3d144bb6b055eac90539 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import init_integration @@ -49,6 +49,7 @@ async def test_sensor_without_forecast(hass): assert state.attributes.get(ATTR_ICON) == "mdi:weather-fog" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_METERS assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DISTANCE entry = registry.async_get("sensor.home_cloud_ceiling") assert entry @@ -435,6 +436,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SPEED_KILOMETERS_PER_HOUR assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED entry = registry.async_get("sensor.home_wind_gust") assert entry @@ -447,6 +449,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SPEED_KILOMETERS_PER_HOUR assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED entry = registry.async_get("sensor.home_wind") assert entry @@ -579,6 +582,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SPEED_KILOMETERS_PER_HOUR assert state.attributes.get("direction") == "SSE" assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED entry = registry.async_get("sensor.home_wind_day_0d") assert entry @@ -592,6 +596,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get("direction") == "WNW" assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED entry = registry.async_get("sensor.home_wind_night_0d") assert entry @@ -605,6 +610,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get("direction") == "S" assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED entry = registry.async_get("sensor.home_wind_gust_day_0d") assert entry @@ -618,6 +624,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get("direction") == "WSW" assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED entry = registry.async_get("sensor.home_wind_gust_night_0d") assert entry @@ -697,7 +704,7 @@ async def test_manual_update_entity(hass): async def test_sensor_imperial_units(hass): """Test states of the sensor without forecast.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await init_integration(hass) state = hass.states.get("sensor.home_cloud_ceiling") diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py index 4bcfb60e7b67f803083cac273546d3df23b74ab8..2fdae7b9d6be4f04f309572fd6a8ad6a5e64b004 100644 --- a/tests/components/adguard/test_config_flow.py +++ b/tests/components/adguard/test_config_flow.py @@ -127,7 +127,9 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: "addon": "AdGuard Home Addon", "host": "mock-adguard", "port": "3000", - } + }, + name="AdGuard Home Addon", + slug="adguard", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -149,7 +151,9 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: "addon": "AdGuard Home Addon", "host": "mock-adguard", "port": "3000", - } + }, + name="AdGuard Home Addon", + slug="adguard", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -171,7 +175,13 @@ async def test_hassio_confirm( result = await hass.config_entries.flow.async_init( DOMAIN, data=HassioServiceInfo( - config={"addon": "AdGuard Home Addon", "host": "mock-adguard", "port": 3000} + config={ + "addon": "AdGuard Home Addon", + "host": "mock-adguard", + "port": 3000, + }, + name="AdGuard Home Addon", + slug="adguard", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -207,7 +217,13 @@ async def test_hassio_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, data=HassioServiceInfo( - config={"addon": "AdGuard Home Addon", "host": "mock-adguard", "port": 3000} + config={ + "addon": "AdGuard Home Addon", + "host": "mock-adguard", + "port": 3000, + }, + name="AdGuard Home Addon", + slug="adguard", ), context={"source": config_entries.SOURCE_HASSIO}, ) diff --git a/tests/components/airnow/conftest.py b/tests/components/airnow/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..47f20ccd883d1800fc632a8a74318af69c834547 --- /dev/null +++ b/tests/components/airnow/conftest.py @@ -0,0 +1,57 @@ +"""Define fixtures for AirNow tests.""" +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.airnow import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}", + data=config, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_API_KEY: "abc123", + CONF_LATITUDE: 34.053718, + CONF_LONGITUDE: -118.244842, + CONF_RADIUS: 75, + } + + +@pytest.fixture(name="data", scope="session") +def data_fixture(): + """Define a fixture for response data.""" + return json.loads(load_fixture("response.json", "airnow")) + + +@pytest.fixture(name="mock_api_get") +def mock_api_get_fixture(data): + """Define a fixture for a mock "get" coroutine function.""" + return AsyncMock(return_value=data) + + +@pytest.fixture(name="setup_airnow") +async def setup_airnow_fixture(hass, config, mock_api_get): + """Define a fixture to set up AirNow.""" + with patch("pyairnow.WebServiceAPI._get", mock_api_get), patch( + "homeassistant.components.airnow.config_flow.WebServiceAPI._get", mock_api_get + ), patch("homeassistant.components.airnow.PLATFORMS", []): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield diff --git a/tests/components/airnow/fixtures/__init__.py b/tests/components/airnow/fixtures/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..328b7a792e208b576169fd968e0604f5f3f3db1f --- /dev/null +++ b/tests/components/airnow/fixtures/__init__.py @@ -0,0 +1 @@ +"""Define AirNow response fixture data.""" diff --git a/tests/components/airnow/fixtures/response.json b/tests/components/airnow/fixtures/response.json new file mode 100644 index 0000000000000000000000000000000000000000..91029f5531f22fd6656361cc0508eed84a168fb0 --- /dev/null +++ b/tests/components/airnow/fixtures/response.json @@ -0,0 +1,47 @@ +[ + { + "DateObserved": "2020-12-20", + "HourObserved": 15, + "LocalTimeZone": "PST", + "ReportingArea": "Central LA CO", + "StateCode": "CA", + "Latitude": 34.0663, + "Longitude": -118.2266, + "ParameterName": "O3", + "AQI": 44, + "Category": { + "Number": 1, + "Name": "Good" + } + }, + { + "DateObserved": "2020-12-20", + "HourObserved": 15, + "LocalTimeZone": "PST", + "ReportingArea": "Central LA CO", + "StateCode": "CA", + "Latitude": 34.0663, + "Longitude": -118.2266, + "ParameterName": "PM2.5", + "AQI": 37, + "Category": { + "Number": 1, + "Name": "Good" + } + }, + { + "DateObserved": "2020-12-20", + "HourObserved": 15, + "LocalTimeZone": "PST", + "ReportingArea": "Central LA CO", + "StateCode": "CA", + "Latitude": 34.0663, + "Longitude": -118.2266, + "ParameterName": "PM10", + "AQI": 11, + "Category": { + "Number": 1, + "Name": "Good" + } + } +] diff --git a/tests/components/airnow/test_config_flow.py b/tests/components/airnow/test_config_flow.py index 02236e826e5e282ef29eec165e216d350f83e662..dddd51c8450cc4c6c22ab95758e39426b6fa5566 100644 --- a/tests/components/airnow/test_config_flow.py +++ b/tests/components/airnow/test_config_flow.py @@ -1,183 +1,75 @@ """Test the AirNow config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock from pyairnow.errors import AirNowError, InvalidKeyError +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.airnow.const import DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS -from tests.common import MockConfigEntry -CONFIG = { - CONF_API_KEY: "abc123", - CONF_LATITUDE: 34.053718, - CONF_LONGITUDE: -118.244842, - CONF_RADIUS: 75, -} - -# Mock AirNow Response -MOCK_RESPONSE = [ - { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "O3", - "AQI": 44, - "Category": { - "Number": 1, - "Name": "Good", - }, - }, - { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "PM2.5", - "AQI": 37, - "Category": { - "Number": 1, - "Name": "Good", - }, - }, - { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "PM10", - "AQI": 11, - "Category": { - "Number": 1, - "Name": "Good", - }, - }, -] - - -async def test_form(hass): +async def test_form(hass, config, setup_airnow): """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.FlowResultType.FORM assert result["errors"] == {} - with patch("pyairnow.WebServiceAPI._get", return_value=MOCK_RESPONSE), patch( - "homeassistant.components.airnow.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - - await hass.async_block_till_done() - + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result2["data"] == CONFIG - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["data"] == config -async def test_form_invalid_auth(hass): +@pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=InvalidKeyError)]) +async def test_form_invalid_auth(hass, config, setup_airnow): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "pyairnow.WebServiceAPI._get", - side_effect=InvalidKeyError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == "form" assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_invalid_location(hass): +@pytest.mark.parametrize("data", [{}]) +async def test_form_invalid_location(hass, config, setup_airnow): """Test we handle invalid location.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch("pyairnow.WebServiceAPI._get", return_value={}): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == "form" assert result2["errors"] == {"base": "invalid_location"} -async def test_form_cannot_connect(hass): +@pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=AirNowError)]) +async def test_form_cannot_connect(hass, config, setup_airnow): """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "pyairnow.WebServiceAPI._get", - side_effect=AirNowError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == "form" assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unexpected(hass): +@pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=RuntimeError)]) +async def test_form_unexpected(hass, config, setup_airnow): """Test we handle an unexpected error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "homeassistant.components.airnow.config_flow.validate_input", - side_effect=RuntimeError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == "form" assert result2["errors"] == {"base": "unknown"} -async def test_entry_already_exists(hass): +async def test_entry_already_exists(hass, config, config_entry): """Test that the form aborts if the Lat/Lng is already configured.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - mock_id = f"{CONFIG[CONF_LATITUDE]}-{CONFIG[CONF_LONGITUDE]}" - mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=mock_id) - mock_entry.add_to_hass(hass) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == "abort" assert result2["reason"] == "already_configured" diff --git a/tests/components/airnow/test_diagnostics.py b/tests/components/airnow/test_diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..76a8a1dc0b276d79b1ec70eb08cafd96fb5e7dca --- /dev/null +++ b/tests/components/airnow/test_diagnostics.py @@ -0,0 +1,43 @@ +"""Test AirNow diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_airnow): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "airnow", + "title": REDACTED, + "data": { + "api_key": REDACTED, + "latitude": REDACTED, + "longitude": REDACTED, + "radius": 75, + }, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + }, + "data": { + "O3": 0.048, + "PM2.5": 8.9, + "HourObserved": 15, + "DateObserved": "2020-12-20", + "StateCode": REDACTED, + "ReportingArea": REDACTED, + "Latitude": REDACTED, + "Longitude": REDACTED, + "PM10": 12, + "AQI": 44, + "Category.Number": 1, + "Category.Name": "Good", + "Pollutant": "O3", + }, + } diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7f8df35f263e016796307c4f838e4d93c3b733d4 --- /dev/null +++ b/tests/components/airthings_ble/__init__.py @@ -0,0 +1,99 @@ +"""Tests for the Airthings BLE integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from bleak.backends.device import BLEDevice + +from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak + +from tests.components.bluetooth import generate_advertisement_data + + +def patch_async_setup_entry(return_value=True): + """Patch async setup entry to return True.""" + return patch( + "homeassistant.components.airthings_ble.async_setup_entry", + return_value=return_value, + ) + + +def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak | None): + """Patch async ble device from address to return a given value.""" + return patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=return_value, + ) + + +def patch_airthings_ble(return_value=AirthingsDevice, side_effect=None): + """Patch airthings-ble device fetcher with given values and effects.""" + return patch.object( + AirthingsBluetoothDeviceData, + "update_device", + return_value=return_value, + side_effect=side_effect, + ) + + +WAVE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="cc-cc-cc-cc-cc-cc", + address="cc:cc:cc:cc:cc:cc", + rssi=-61, + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_data={}, + service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], + source="local", + device=BLEDevice( + "cc:cc:cc:cc:cc:cc", + "cc-cc-cc-cc-cc-cc", + ), + advertisement=generate_advertisement_data( + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], + ), + connectable=True, + time=0, +) + +UNKNOWN_SERVICE_INFO = BluetoothServiceInfoBleak( + name="unknown", + address="00:cc:cc:cc:cc:cc", + rssi=-61, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", + device=BLEDevice( + "cc:cc:cc:cc:cc:cc", + "unknown", + ), + advertisement=generate_advertisement_data( + manufacturer_data={}, + service_uuids=[], + ), + connectable=True, + time=0, +) + +WAVE_DEVICE_INFO = AirthingsDevice( + hw_version="REV A", + sw_version="G-BLE-1.5.3-master+0", + name="Airthings Wave+", + identifier="123456", + sensors={ + "illuminance": 25, + "battery": 85, + "humidity": 60.0, + "radon_1day_avg": 30, + "radon_longterm_avg": 30, + "temperature": 21.0, + "co2": 500.0, + "voc": 155.0, + "radon_1day_level": "very low", + "radon_longterm_level": "very low", + "pressure": 1020, + }, + address="cc:cc:cc:cc:cc:cc", +) diff --git a/tests/components/airthings_ble/conftest.py b/tests/components/airthings_ble/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..3df082c4361d67244bb395db5b57b820aa6da406 --- /dev/null +++ b/tests/components/airthings_ble/conftest.py @@ -0,0 +1,8 @@ +"""Define fixtures available for all tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..ddddcdbc94a7cb40f51ef4eea339a736a0d3885a --- /dev/null +++ b/tests/components/airthings_ble/test_config_flow.py @@ -0,0 +1,194 @@ +"""Test the Airthings BLE config flow.""" +from unittest.mock import patch + +from airthings_ble import AirthingsDevice +from bleak import BleakError + +from homeassistant.components.airthings_ble.const import DOMAIN +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + UNKNOWN_SERVICE_INFO, + WAVE_DEVICE_INFO, + WAVE_SERVICE_INFO, + patch_airthings_ble, + patch_async_ble_device_from_address, + patch_async_setup_entry, +) + +from tests.common import MockConfigEntry + + +async def test_bluetooth_discovery(hass: HomeAssistant): + """Test discovery via bluetooth with a valid device.""" + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble( + AirthingsDevice(name="Airthings Wave+", identifier="123456") + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_SERVICE_INFO, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["description_placeholders"] == {"name": "Airthings Wave+ (123456)"} + + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"not": "empty"} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings Wave+ (123456)" + assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" + + +async def test_bluetooth_discovery_no_BLEDevice(hass: HomeAssistant): + """Test discovery via bluetooth but there's no BLEDevice.""" + with patch_async_ble_device_from_address(None): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_bluetooth_discovery_airthings_ble_update_failed( + hass: HomeAssistant, +): + """Test discovery via bluetooth but there's an exception from airthings-ble.""" + for loop in [(Exception(), "unknown"), (BleakError(), "cannot_connect")]: + exc, reason = loop + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble(side_effect=exc): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_SERVICE_INFO, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_bluetooth_discovery_already_setup(hass: HomeAssistant): + """Test discovery via bluetooth with a valid device when already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="cc:cc:cc:cc:cc:cc", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_DEVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_setup(hass: HomeAssistant): + """Test the user initiated form.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[WAVE_SERVICE_INFO], + ): + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble( + AirthingsDevice(name="Airthings Wave+", identifier="123456") + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + schema = result["data_schema"].schema + + assert schema.get(CONF_ADDRESS).container == { + "cc:cc:cc:cc:cc:cc": "Airthings Wave+ (123456)" + } + + with patch( + "homeassistant.components.airthings_ble.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"} + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings Wave+ (123456)" + assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" + + +async def test_user_setup_no_device(hass: HomeAssistant): + """Test the user initiated form without any device detected.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_setup_existing_and_unknown_device(hass: HomeAssistant): + """Test the user initiated form with existing devices and unknown ones.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="cc:cc:cc:cc:cc:cc", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[UNKNOWN_SERVICE_INFO, WAVE_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_setup_unknown_error(hass: HomeAssistant): + """Test the user initiated form with an unknown error.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[WAVE_SERVICE_INFO], + ): + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble(None, Exception()): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_user_setup_unable_to_connect(hass: HomeAssistant): + """Test the user initiated form with a device that's failing connection.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[WAVE_SERVICE_INFO], + ): + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble(side_effect=BleakError("An error")): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/airvisual/conftest.py b/tests/components/airvisual/conftest.py index 81b22f19cc5610c867fe991b2dff95b9fe06d458..3e83b41a5af72ca0a6805c52e69ad9406944895f 100644 --- a/tests/components/airvisual/conftest.py +++ b/tests/components/airvisual/conftest.py @@ -50,7 +50,7 @@ def config_fixture(hass): } -@pytest.fixture(name="data", scope="session") +@pytest.fixture(name="data", scope="package") def data_fixture(): """Define an update coordinator data example.""" return json.loads(load_fixture("data.json", "airvisual")) diff --git a/tests/components/airvisual/test_diagnostics.py b/tests/components/airvisual/test_diagnostics.py index 5b68644bb7e77b5ef906006e2baf44d8191d25ce..72ed5298f96a40f7514b650aa06afbfa0910cd6b 100644 --- a/tests/components/airvisual/test_diagnostics.py +++ b/tests/components/airvisual/test_diagnostics.py @@ -8,20 +8,28 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_airvisua """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { - "title": "Mock Title", + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "airvisual", + "title": REDACTED, "data": { - "api_key": REDACTED, "integration_type": "Geographical Location by Latitude/Longitude", + "api_key": REDACTED, "latitude": REDACTED, "longitude": REDACTED, }, - "options": { - "show_on_map": True, - }, + "options": {"show_on_map": True}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { "city": REDACTED, + "state": REDACTED, "country": REDACTED, + "location": {"type": "Point", "coordinates": REDACTED}, "current": { "weather": { "ts": "2021-09-03T21:00:00.000Z", @@ -40,10 +48,5 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_airvisua "maincn": "p2", }, }, - "location": { - "coordinates": REDACTED, - "type": "Point", - }, - "state": REDACTED, }, } diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index ef21b463a127f370e8a18ea0e9efd37b11a450e1..f1543892b6b3b7b9bcd241e513862b62b3cc16e9 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -5,12 +5,21 @@ from copy import deepcopy import pytest import homeassistant.components.alert as alert -from homeassistant.components.alert import DOMAIN +from homeassistant.components.alert.const import ( + CONF_ALERT_MESSAGE, + CONF_DATA, + CONF_DONE_MESSAGE, + CONF_NOTIFIERS, + CONF_SKIP_FIRST, + CONF_TITLE, + DOMAIN, +) import homeassistant.components.notify as notify from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ENTITY_ID, CONF_NAME, + CONF_REPEAT, CONF_STATE, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -19,9 +28,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component +from tests.common import async_mock_service + NAME = "alert_test" DONE_MESSAGE = "alert_gone" NOTIFIER = "test" @@ -31,17 +42,17 @@ TITLE = "{{ states.sensor.test.entity_id }}" TEST_TITLE = "sensor.test" TEST_DATA = {"data": {"inline_keyboard": ["Close garage:/close_garage"]}} TEST_CONFIG = { - alert.DOMAIN: { + DOMAIN: { NAME: { CONF_NAME: NAME, - alert.CONF_DONE_MESSAGE: DONE_MESSAGE, + CONF_DONE_MESSAGE: DONE_MESSAGE, CONF_ENTITY_ID: TEST_ENTITY, CONF_STATE: STATE_ON, - alert.CONF_REPEAT: 30, - alert.CONF_SKIP_FIRST: False, - alert.CONF_NOTIFIERS: [NOTIFIER], - alert.CONF_TITLE: TITLE, - alert.CONF_DATA: {}, + CONF_REPEAT: 30, + CONF_SKIP_FIRST: False, + CONF_NOTIFIERS: [NOTIFIER], + CONF_TITLE: TITLE, + CONF_DATA: {}, } } } @@ -59,73 +70,24 @@ TEST_NOACK = [ None, None, ] -ENTITY_ID = f"{alert.DOMAIN}.{NAME}" - - -@callback -def async_turn_on(hass, entity_id): - """Async reset the alert. - - This is a legacy helper method. Do not use it for new tests. - """ - data = {ATTR_ENTITY_ID: entity_id} - hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data)) - - -@callback -def async_turn_off(hass, entity_id): - """Async acknowledge the alert. - - This is a legacy helper method. Do not use it for new tests. - """ - data = {ATTR_ENTITY_ID: entity_id} - hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data)) - - -@callback -def async_toggle(hass, entity_id): - """Async toggle acknowledgment of alert. - - This is a legacy helper method. Do not use it for new tests. - """ - data = {ATTR_ENTITY_ID: entity_id} - hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_TOGGLE, data)) +ENTITY_ID = f"{DOMAIN}.{NAME}" @pytest.fixture -def mock_notifier(hass): +def mock_notifier(hass: HomeAssistant) -> list[ServiceCall]: """Mock for notifier.""" - events = [] - - @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) - - hass.services.async_register(notify.DOMAIN, NOTIFIER, record_event) - - return events - - -async def test_is_on(hass): - """Test is_on method.""" - hass.states.async_set(ENTITY_ID, STATE_ON) - await hass.async_block_till_done() - assert alert.is_on(hass, ENTITY_ID) - hass.states.async_set(ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() - assert not alert.is_on(hass, ENTITY_ID) + return async_mock_service(hass, notify.DOMAIN, NOTIFIER) async def test_setup(hass): """Test setup method.""" - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) assert hass.states.get(ENTITY_ID).state == STATE_IDLE async def test_fire(hass, mock_notifier): """Test the alert firing.""" - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_ON @@ -133,11 +95,16 @@ async def test_fire(hass, mock_notifier): async def test_silence(hass, mock_notifier): """Test silencing the alert.""" - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() - async_turn_off(hass, ENTITY_ID) - await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) assert hass.states.get(ENTITY_ID).state == STATE_OFF # alert should not be silenced on next fire @@ -151,82 +118,119 @@ async def test_silence(hass, mock_notifier): async def test_reset(hass, mock_notifier): """Test resetting the alert.""" - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() - async_turn_off(hass, ENTITY_ID) - await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert hass.states.get(ENTITY_ID).state == STATE_OFF - async_turn_on(hass, ENTITY_ID) - await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) assert hass.states.get(ENTITY_ID).state == STATE_ON async def test_toggle(hass, mock_notifier): """Test toggling alert.""" - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_ON - async_toggle(hass, ENTITY_ID) - await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) assert hass.states.get(ENTITY_ID).state == STATE_OFF - async_toggle(hass, ENTITY_ID) - await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) assert hass.states.get(ENTITY_ID).state == STATE_ON -async def test_notification_no_done_message(hass): +async def test_notification_no_done_message( + hass: HomeAssistant, mock_notifier: list[ServiceCall] +) -> None: """Test notifications.""" - events = [] config = deepcopy(TEST_CONFIG) - del config[alert.DOMAIN][NAME][alert.CONF_DONE_MESSAGE] - - @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) - - hass.services.async_register(notify.DOMAIN, NOTIFIER, record_event) + del config[DOMAIN][NAME][CONF_DONE_MESSAGE] - assert await async_setup_component(hass, alert.DOMAIN, config) - assert len(events) == 0 + assert await async_setup_component(hass, DOMAIN, config) + assert len(mock_notifier) == 0 hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() - assert len(events) == 1 + assert len(mock_notifier) == 1 hass.states.async_set("sensor.test", STATE_OFF) await hass.async_block_till_done() - assert len(events) == 1 + assert len(mock_notifier) == 1 -async def test_notification(hass): +async def test_notification( + hass: HomeAssistant, mock_notifier: list[ServiceCall] +) -> None: """Test notifications.""" - events = [] + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) + assert len(mock_notifier) == 0 - @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) + hass.states.async_set("sensor.test", STATE_ON) + await hass.async_block_till_done() + assert len(mock_notifier) == 1 + + hass.states.async_set("sensor.test", STATE_OFF) + await hass.async_block_till_done() + assert len(mock_notifier) == 2 - hass.services.async_register(notify.DOMAIN, NOTIFIER, record_event) - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) - assert len(events) == 0 +async def test_no_notifiers( + hass: HomeAssistant, mock_notifier: list[ServiceCall] +) -> None: + """Test we send no notifications when there are not no.""" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + NAME: { + CONF_NAME: NAME, + CONF_ENTITY_ID: TEST_ENTITY, + CONF_STATE: STATE_ON, + CONF_REPEAT: 30, + } + } + }, + ) + assert len(mock_notifier) == 0 hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() - assert len(events) == 1 + assert len(mock_notifier) == 0 hass.states.async_set("sensor.test", STATE_OFF) await hass.async_block_till_done() - assert len(events) == 2 + assert len(mock_notifier) == 0 async def test_sending_non_templated_notification(hass, mock_notifier): """Test notifications.""" - assert await async_setup_component(hass, alert.DOMAIN, TEST_CONFIG) + assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) hass.states.async_set(TEST_ENTITY, STATE_ON) await hass.async_block_till_done() @@ -238,8 +242,8 @@ async def test_sending_non_templated_notification(hass, mock_notifier): async def test_sending_templated_notification(hass, mock_notifier): """Test templated notification.""" config = deepcopy(TEST_CONFIG) - config[alert.DOMAIN][NAME][alert.CONF_ALERT_MESSAGE] = TEMPLATE - assert await async_setup_component(hass, alert.DOMAIN, config) + config[DOMAIN][NAME][CONF_ALERT_MESSAGE] = TEMPLATE + assert await async_setup_component(hass, DOMAIN, config) hass.states.async_set(TEST_ENTITY, STATE_ON) await hass.async_block_till_done() @@ -251,8 +255,8 @@ async def test_sending_templated_notification(hass, mock_notifier): async def test_sending_templated_done_notification(hass, mock_notifier): """Test templated notification.""" config = deepcopy(TEST_CONFIG) - config[alert.DOMAIN][NAME][alert.CONF_DONE_MESSAGE] = TEMPLATE - assert await async_setup_component(hass, alert.DOMAIN, config) + config[DOMAIN][NAME][CONF_DONE_MESSAGE] = TEMPLATE + assert await async_setup_component(hass, DOMAIN, config) hass.states.async_set(TEST_ENTITY, STATE_ON) await hass.async_block_till_done() @@ -266,8 +270,8 @@ async def test_sending_templated_done_notification(hass, mock_notifier): async def test_sending_titled_notification(hass, mock_notifier): """Test notifications.""" config = deepcopy(TEST_CONFIG) - config[alert.DOMAIN][NAME][alert.CONF_TITLE] = TITLE - assert await async_setup_component(hass, alert.DOMAIN, config) + config[DOMAIN][NAME][CONF_TITLE] = TITLE + assert await async_setup_component(hass, DOMAIN, config) hass.states.async_set(TEST_ENTITY, STATE_ON) await hass.async_block_till_done() @@ -279,8 +283,8 @@ async def test_sending_titled_notification(hass, mock_notifier): async def test_sending_data_notification(hass, mock_notifier): """Test notifications.""" config = deepcopy(TEST_CONFIG) - config[alert.DOMAIN][NAME][alert.CONF_DATA] = TEST_DATA - assert await async_setup_component(hass, alert.DOMAIN, config) + config[DOMAIN][NAME][CONF_DATA] = TEST_DATA + assert await async_setup_component(hass, DOMAIN, config) hass.states.async_set(TEST_ENTITY, STATE_ON) await hass.async_block_till_done() @@ -289,25 +293,16 @@ async def test_sending_data_notification(hass, mock_notifier): assert last_event.data[notify.ATTR_DATA] == TEST_DATA -async def test_skipfirst(hass): +async def test_skipfirst(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> None: """Test skipping first notification.""" config = deepcopy(TEST_CONFIG) - config[alert.DOMAIN][NAME][alert.CONF_SKIP_FIRST] = True - events = [] - - @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) - - hass.services.async_register(notify.DOMAIN, NOTIFIER, record_event) - - assert await async_setup_component(hass, alert.DOMAIN, config) - assert len(events) == 0 + config[DOMAIN][NAME][CONF_SKIP_FIRST] = True + assert await async_setup_component(hass, DOMAIN, config) + assert len(mock_notifier) == 0 hass.states.async_set("sensor.test", STATE_ON) await hass.async_block_till_done() - assert len(events) == 0 + assert len(mock_notifier) == 0 async def test_done_message_state_tracker_reset_on_cancel(hass): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 9d329e76c45c3552da892eb6214d5d1666702f74..e2ae8741f203e6404f32393c9740cb761a40b19e 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -30,7 +30,7 @@ from homeassistant.const import STATE_UNKNOWN, TEMP_FAHRENHEIT from homeassistant.core import Context from homeassistant.helpers import entityfilter from homeassistant.setup import async_setup_component -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .test_common import ( MockConfig, @@ -2020,7 +2020,7 @@ async def test_unknown_sensor(hass): async def test_thermostat(hass): """Test thermostat discovery.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM device = ( "climate.test_thermostat", "cool", diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py index 3bf2db14b95e2949e873f85253f9ea04f532564a..511a5cf08dc47fa28637747190d29bfb525b5ec3 100644 --- a/tests/components/almond/test_config_flow.py +++ b/tests/components/almond/test_config_flow.py @@ -53,7 +53,9 @@ async def test_hassio(hass): DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, data=HassioServiceInfo( - config={"addon": "Almond add-on", "host": "almond-addon", "port": "1234"} + config={"addon": "Almond add-on", "host": "almond-addon", "port": "1234"}, + name="Almond add-on", + slug="almond", ), ) @@ -90,7 +92,9 @@ async def test_abort_if_existing_entry(hass): assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" - result = await flow.async_step_hassio(HassioServiceInfo(config={})) + result = await flow.async_step_hassio( + HassioServiceInfo(config={}, name="Almond add-on", slug="almond") + ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/ambient_station/conftest.py b/tests/components/ambient_station/conftest.py index 680fa82303dfb5099d3edc6b2b832a12ef30dc9e..89dc4e88fb362d7f1353db7a3aeee39b75b3f2af 100644 --- a/tests/components/ambient_station/conftest.py +++ b/tests/components/ambient_station/conftest.py @@ -28,7 +28,7 @@ def config_entry_fixture(hass, config): return entry -@pytest.fixture(name="devices", scope="session") +@pytest.fixture(name="devices", scope="package") def devices_fixture(): """Define devices data.""" return json.loads(load_fixture("devices.json", "ambient_station")) @@ -48,7 +48,7 @@ async def setup_ambient_station_fixture(hass, config, devices): yield -@pytest.fixture(name="station_data", scope="session") +@pytest.fixture(name="station_data", scope="package") def station_data_fixture(): """Define devices data.""" return json.loads(load_fixture("station_data.json", "ambient_station")) diff --git a/tests/components/ambient_station/test_diagnostics.py b/tests/components/ambient_station/test_diagnostics.py index 63d5fcff7a18a3500b60cd601ace30be72c36b70..e6285afa17aa9f28e200fcee18264bd061de167a 100644 --- a/tests/components/ambient_station/test_diagnostics.py +++ b/tests/components/ambient_station/test_diagnostics.py @@ -13,45 +13,54 @@ async def test_entry_diagnostics( ambient.stations = station_data assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "ambient_station", + "title": REDACTED, "data": {"api_key": REDACTED, "app_key": REDACTED}, - "title": "Mock Title", + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "stations": { "devices": [ { - "apiKey": REDACTED, - "info": {"location": REDACTED, "name": "Side Yard"}, + "macAddress": REDACTED, "lastData": { - "baromabsin": 25.016, - "baromrelin": 29.953, - "batt_co2": 1, - "dailyrainin": 0, - "date": "2022-01-19T22:38:00.000Z", "dateutc": 1642631880000, - "deviceId": REDACTED, - "dewPoint": 17.75, - "dewPointin": 37, - "eventrainin": 0, - "feelsLike": 21, - "feelsLikein": 69.1, - "hourlyrainin": 0, - "humidity": 87, + "tempinf": 70.9, "humidityin": 29, - "lastRain": "2022-01-07T19:45:00.000Z", + "baromrelin": 29.953, + "baromabsin": 25.016, + "tempf": 21, + "humidity": 87, + "winddir": 25, + "windspeedmph": 0.2, + "windgustmph": 1.1, "maxdailygust": 9.2, + "hourlyrainin": 0, + "eventrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, "monthlyrainin": 0.409, - "solarradiation": 11.62, - "tempf": 21, - "tempinf": 70.9, "totalrainin": 35.398, - "tz": REDACTED, + "solarradiation": 11.62, "uv": 0, - "weeklyrainin": 0, - "winddir": 25, - "windgustmph": 1.1, - "windspeedmph": 0.2, + "batt_co2": 1, + "feelsLike": 21, + "dewPoint": 17.75, + "feelsLikein": 69.1, + "dewPointin": 37, + "lastRain": "2022-01-07T19:45:00.000Z", + "deviceId": REDACTED, + "tz": REDACTED, + "date": "2022-01-19T22:38:00.000Z", }, - "macAddress": REDACTED, + "info": {"name": "Side Yard", "location": REDACTED}, + "apiKey": REDACTED, } ], "method": "subscribe", diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 82a611264321be0e9d810335142c1d55e8bc3f90..b73338add35975eb0bc9dd9960048cd80dd7c185 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -451,7 +451,7 @@ async def test_send_with_no_energy(hass, aioclient_mock): assert "energy" not in postdata -async def test_send_with_no_energy_config(hass, aioclient_mock, recorder_mock): +async def test_send_with_no_energy_config(recorder_mock, hass, aioclient_mock): """Test send base preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) @@ -473,7 +473,7 @@ async def test_send_with_no_energy_config(hass, aioclient_mock, recorder_mock): assert not postdata["energy"]["configured"] -async def test_send_with_energy_config(hass, aioclient_mock, recorder_mock): +async def test_send_with_energy_config(recorder_mock, hass, aioclient_mock): """Test send base preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) diff --git a/tests/components/android_ip_webcam/test_config_flow.py b/tests/components/android_ip_webcam/test_config_flow.py index d203ef15e635be351d53ee26eb23b00a768e58b3..881585ed5dc41540ac67e466440910a2322a55b4 100644 --- a/tests/components/android_ip_webcam/test_config_flow.py +++ b/tests/components/android_ip_webcam/test_config_flow.py @@ -1,5 +1,4 @@ """Test the Android IP Webcam config flow.""" -from datetime import timedelta from unittest.mock import Mock, patch import aiohttp @@ -45,35 +44,6 @@ async def test_form(hass: HomeAssistant, aioclient_mock_fixture) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_flow_success(hass: HomeAssistant, aioclient_mock_fixture) -> None: - """Test a successful import of yaml.""" - with patch( - "homeassistant.components.android_ip_webcam.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "name": "IP Webcam", - "host": "1.1.1.1", - "port": 8080, - "timeout": 10, - "scan_interval": timedelta(seconds=30), - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "IP Webcam" - assert result2["data"] == { - "name": "IP Webcam", - "host": "1.1.1.1", - "port": 8080, - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_device_already_configured( hass: HomeAssistant, aioclient_mock_fixture ) -> None: diff --git a/tests/components/android_ip_webcam/test_init.py b/tests/components/android_ip_webcam/test_init.py index 1fee1a5c388c87686e00ab6447c79c03520a96d1..fa5f551e9b1a800a8dcb0df01d274bcae5925a0e 100644 --- a/tests/components/android_ip_webcam/test_init.py +++ b/tests/components/android_ip_webcam/test_init.py @@ -1,8 +1,6 @@ """Tests for the Android IP Webcam integration.""" -from collections.abc import Awaitable -from typing import Callable from unittest.mock import Mock import aiohttp @@ -10,10 +8,8 @@ import aiohttp from homeassistant.components.android_ip_webcam.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.components.repairs import get_repairs from tests.test_util.aiohttp import AiohttpClientMocker MOCK_CONFIG_DATA = { @@ -25,21 +21,6 @@ MOCK_CONFIG_DATA = { } -async def test_setup( - hass: HomeAssistant, - aioclient_mock_fixture, - hass_ws_client: Callable[ - [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] - ], -) -> None: - """Test integration failed due to an error.""" - assert await async_setup_component(hass, DOMAIN, {DOMAIN: [MOCK_CONFIG_DATA]}) - assert hass.config_entries.async_entries(DOMAIN) - issues = await get_repairs(hass, hass_ws_client) - assert len(issues) == 1 - assert issues[0]["issue_id"] == "deprecated_yaml" - - async def test_successful_config_entry( hass: HomeAssistant, aioclient_mock_fixture ) -> None: diff --git a/tests/components/anthemav/test_config_flow.py b/tests/components/anthemav/test_config_flow.py index f8bec435dc65aa2eabdd8c097a01237109962cc4..e62fb4ba52c47fec18da75d03ea78490a23fff14 100644 --- a/tests/components/anthemav/test_config_flow.py +++ b/tests/components/anthemav/test_config_flow.py @@ -2,10 +2,9 @@ from unittest.mock import AsyncMock, patch from anthemav.device_error import DeviceError -import pytest from homeassistant.components.anthemav.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -95,36 +94,11 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def test_import_configuration( - hass: HomeAssistant, mock_connection_create: AsyncMock, mock_anthemav: AsyncMock -) -> None: - """Test we import existing configuration.""" - config = { - "host": "1.1.1.1", - "port": 14999, - "name": "Anthem Av Import", - } - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == { - "host": "1.1.1.1", - "port": 14999, - "name": "Anthem Av Import", - "mac": "00:00:00:00:00:01", - "model": "MRX 520", - } - - -@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) async def test_device_already_configured( hass: HomeAssistant, mock_connection_create: AsyncMock, mock_anthemav: AsyncMock, mock_config_entry: MockConfigEntry, - source: str, ) -> None: """Test we import existing configuration.""" config = { @@ -134,7 +108,7 @@ async def test_device_already_configured( mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source}, data=config + DOMAIN, context={"source": SOURCE_USER}, data=config ) assert result.get("type") == FlowResultType.ABORT diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 3cdf0c3a477ac1c5dcbaef11b25159c09554fa4c..f40309bf7f6eff73adf0b2b986c9b6f6a514722e 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -15,6 +15,7 @@ from homeassistant.components.automation import ( EVENT_AUTOMATION_RELOADED, EVENT_AUTOMATION_TRIGGERED, SERVICE_TRIGGER, + AutomationEntity, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -45,6 +46,7 @@ from homeassistant.helpers.script import ( _async_stop_scripts_at_shutdown, ) from homeassistant.setup import async_setup_component +from homeassistant.util import yaml import homeassistant.util.dt as dt_util from tests.common import ( @@ -720,6 +722,7 @@ async def test_automation_stops(hass, calls, service): blocking=True, ) else: + config[automation.DOMAIN]["alias"] = "goodbye" with patch( "homeassistant.config.load_yaml_config_file", autospec=True, @@ -735,6 +738,326 @@ async def test_automation_stops(hass, calls, service): assert len(calls) == (1 if service == "turn_off_no_stop" else 0) +async def test_reload_unchanged_does_not_stop(hass, calls): + """Test that reloading stops any running actions as appropriate.""" + test_entity = "test.entity" + + config = { + automation.DOMAIN: { + "alias": "hello", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + {"event": "running"}, + {"wait_template": "{{ is_state('test.entity', 'goodbye') }}"}, + {"service": "test.automation"}, + ], + } + } + assert await async_setup_component(hass, automation.DOMAIN, config) + + running = asyncio.Event() + + @callback + def running_cb(event): + running.set() + + hass.bus.async_listen_once("running", running_cb) + hass.states.async_set(test_entity, "hello") + + hass.bus.async_fire("test_event") + await running.wait() + assert len(calls) == 0 + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) + + hass.states.async_set(test_entity, "goodbye") + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_reload_moved_automation_without_alias(hass, calls): + """Test that changing the order of automations without alias triggers reload.""" + with patch( + "homeassistant.components.automation.AutomationEntity", wraps=AutomationEntity + ) as automation_entity_init: + config = { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + }, + { + "alias": "automation_with_alias", + "trigger": {"platform": "event", "event_type": "test_event2"}, + "action": [{"service": "test.automation"}], + }, + ] + } + assert await async_setup_component(hass, automation.DOMAIN, config) + assert automation_entity_init.call_count == 2 + automation_entity_init.reset_mock() + + assert hass.states.get("automation.automation_0") + assert not hass.states.get("automation.automation_1") + assert hass.states.get("automation.automation_with_alias") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + # Reverse the order of the automations + config[automation.DOMAIN].reverse() + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, SERVICE_RELOAD, blocking=True + ) + + assert automation_entity_init.call_count == 1 + automation_entity_init.reset_mock() + + assert not hass.states.get("automation.automation_0") + assert hass.states.get("automation.automation_1") + assert hass.states.get("automation.automation_with_alias") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 2 + + +async def test_reload_identical_automations_without_id(hass, calls): + """Test reloading of identical automations without id.""" + with patch( + "homeassistant.components.automation.AutomationEntity", wraps=AutomationEntity + ) as automation_entity_init: + config = { + automation.DOMAIN: [ + { + "alias": "dolly", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + }, + { + "alias": "dolly", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + }, + { + "alias": "dolly", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + }, + ] + } + assert await async_setup_component(hass, automation.DOMAIN, config) + assert automation_entity_init.call_count == 3 + automation_entity_init.reset_mock() + + assert hass.states.get("automation.dolly") + assert hass.states.get("automation.dolly_2") + assert hass.states.get("automation.dolly_3") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 3 + + # Reload the automations without any change + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, SERVICE_RELOAD, blocking=True + ) + + assert automation_entity_init.call_count == 0 + automation_entity_init.reset_mock() + + assert hass.states.get("automation.dolly") + assert hass.states.get("automation.dolly_2") + assert hass.states.get("automation.dolly_3") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 6 + + # Remove two clones + del config[automation.DOMAIN][-1] + del config[automation.DOMAIN][-1] + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, SERVICE_RELOAD, blocking=True + ) + + assert automation_entity_init.call_count == 0 + automation_entity_init.reset_mock() + + assert hass.states.get("automation.dolly") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 7 + + # Add two clones + config[automation.DOMAIN].append(config[automation.DOMAIN][-1]) + config[automation.DOMAIN].append(config[automation.DOMAIN][-1]) + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, SERVICE_RELOAD, blocking=True + ) + + assert automation_entity_init.call_count == 2 + automation_entity_init.reset_mock() + + assert hass.states.get("automation.dolly") + assert hass.states.get("automation.dolly_2") + assert hass.states.get("automation.dolly_3") + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 10 + + +@pytest.mark.parametrize( + "automation_config", + ( + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + }, + # An automation using templates + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "{{ 'test.automation' }}"}], + }, + # An automation using blueprint + { + "use_blueprint": { + "path": "test_event_service.yaml", + "input": { + "trigger_event": "test_event", + "service_to_call": "test.automation", + "a_number": 5, + }, + } + }, + # An automation using blueprint with templated input + { + "use_blueprint": { + "path": "test_event_service.yaml", + "input": { + "trigger_event": "{{ 'test_event' }}", + "service_to_call": "{{ 'test.automation' }}", + "a_number": 5, + }, + } + }, + ), +) +async def test_reload_unchanged_automation(hass, calls, automation_config): + """Test an unmodified automation is not reloaded.""" + with patch( + "homeassistant.components.automation.AutomationEntity", wraps=AutomationEntity + ) as automation_entity_init: + config = {automation.DOMAIN: [automation_config]} + assert await async_setup_component(hass, automation.DOMAIN, config) + assert automation_entity_init.call_count == 1 + automation_entity_init.reset_mock() + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + # Reload the automations without any change + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, SERVICE_RELOAD, blocking=True + ) + + assert automation_entity_init.call_count == 0 + automation_entity_init.reset_mock() + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 2 + + +async def test_reload_automation_when_blueprint_changes(hass, calls): + """Test an automation is updated at reload if the blueprint has changed.""" + with patch( + "homeassistant.components.automation.AutomationEntity", wraps=AutomationEntity + ) as automation_entity_init: + config = { + automation.DOMAIN: [ + { + "use_blueprint": { + "path": "test_event_service.yaml", + "input": { + "trigger_event": "test_event", + "service_to_call": "test.automation", + "a_number": 5, + }, + } + } + ] + } + assert await async_setup_component(hass, automation.DOMAIN, config) + assert automation_entity_init.call_count == 1 + automation_entity_init.reset_mock() + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + # Reload the automations without any change, but with updated blueprint + blueprint_path = automation.async_get_blueprints(hass).blueprint_folder + blueprint_config = yaml.load_yaml(blueprint_path / "test_event_service.yaml") + blueprint_config["action"] = [blueprint_config["action"]] + blueprint_config["action"].append(blueprint_config["action"][-1]) + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ), patch( + "homeassistant.components.blueprint.models.yaml.load_yaml", + autospec=True, + return_value=blueprint_config, + ): + await hass.services.async_call( + automation.DOMAIN, SERVICE_RELOAD, blocking=True + ) + + assert automation_entity_init.call_count == 1 + automation_entity_init.reset_mock() + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 3 + + async def test_automation_restore_state(hass): """Ensure states are restored on startup.""" time = dt_util.utcnow() diff --git a/tests/components/automation/test_recorder.py b/tests/components/automation/test_recorder.py index 4067393b76c7962928f8621e61496c0916e99d3f..8ce543a3f475dce63e9977024bd390acec5fdb18 100644 --- a/tests/components/automation/test_recorder.py +++ b/tests/components/automation/test_recorder.py @@ -27,7 +27,7 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -async def test_exclude_attributes(hass, recorder_mock, calls): +async def test_exclude_attributes(recorder_mock, hass, calls): """Test automation registered attributes to be excluded.""" assert await async_setup_component( hass, diff --git a/tests/components/aws/test_init.py b/tests/components/aws/test_init.py index 0ad1de9a7a51e379835bb5ed1149193a9807e7ed..3b1dcaea1e5e3212bc71982d0707eccc760bee9f 100644 --- a/tests/components/aws/test_init.py +++ b/tests/components/aws/test_init.py @@ -1,5 +1,6 @@ """Tests for the aws component config and setup.""" -from unittest.mock import AsyncMock, MagicMock, patch as async_patch +import json +from unittest.mock import AsyncMock, MagicMock, call, patch as async_patch from homeassistant.setup import async_setup_component @@ -13,6 +14,7 @@ class MockAioSession: self.invoke = AsyncMock() self.publish = AsyncMock() self.send_message = AsyncMock() + self.put_events = AsyncMock() def create_client(self, *args, **kwargs): """Create a mocked client.""" @@ -23,6 +25,7 @@ class MockAioSession: invoke=self.invoke, # lambda publish=self.publish, # sns send_message=self.send_message, # sqs + put_events=self.put_events, # events ) ), __aexit__=AsyncMock(), @@ -289,3 +292,111 @@ async def test_service_call_extra_data(hass): "AWS.SNS.SMS.SenderID": {"StringValue": "HA-notify", "DataType": "String"} }, ) + + +async def test_events_service_call(hass): + """Test events service (EventBridge) call works as expected.""" + mock_session = MockAioSession() + with async_patch( + "homeassistant.components.aws.AioSession", return_value=mock_session + ): + await async_setup_component( + hass, + "aws", + { + "aws": { + "notify": [ + { + "service": "events", + "name": "Events Test", + "region_name": "us-east-1", + } + ] + } + }, + ) + await hass.async_block_till_done() + + assert hass.services.has_service("notify", "events_test") is True + + mock_session.put_events.return_value = { + "Entries": [{"EventId": "", "ErrorCode": 0, "ErrorMessage": "test-error"}] + } + + await hass.services.async_call( + "notify", + "events_test", + { + "message": "test", + "target": "ARN", + "data": {}, + }, + blocking=True, + ) + + mock_session.put_events.assert_called_once_with( + Entries=[ + { + "EventBusName": "ARN", + "Detail": json.dumps({"message": "test"}), + "DetailType": "", + "Source": "homeassistant", + "Resources": [], + } + ] + ) + + +async def test_events_service_call_10_targets(hass): + """Test events service (EventBridge) call works with more than 10 targets.""" + mock_session = MockAioSession() + with async_patch( + "homeassistant.components.aws.AioSession", return_value=mock_session + ): + await async_setup_component( + hass, + "aws", + { + "aws": { + "notify": [ + { + "service": "events", + "name": "Events Test", + "region_name": "us-east-1", + } + ] + } + }, + ) + await hass.async_block_till_done() + + assert hass.services.has_service("notify", "events_test") is True + await hass.services.async_call( + "notify", + "events_test", + { + "message": "", + "target": [f"eventbus{i}" for i in range(11)], + "data": { + "detail_type": "test_event", + "detail": {"eventkey": "eventvalue"}, + "source": "HomeAssistant-test", + "resources": ["resource1", "resource2"], + }, + }, + blocking=True, + ) + + entry = { + "Detail": json.dumps({"eventkey": "eventvalue"}), + "DetailType": "test_event", + "Source": "HomeAssistant-test", + "Resources": ["resource1", "resource2"], + } + + mock_session.put_events.assert_has_calls( + [ + call(Entries=[entry | {"EventBusName": f"eventbus{i}"} for i in range(10)]), + call(Entries=[entry | {"EventBusName": "eventbus10"}]), + ] + ) diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py index e16033c66a2e49e874688efc0f5b3a748b8cc89b..4dd42705026183ff8f41c90d5ed5dabb84dad798 100644 --- a/tests/components/bayesian/test_binary_sensor.py +++ b/tests/components/bayesian/test_binary_sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, callback +from homeassistant.helpers.entity_registry import async_get as async_get_entities from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.issue_registry import async_get from homeassistant.setup import async_setup_component @@ -31,6 +32,8 @@ async def test_load_values_when_added_to_hass(hass): "binary_sensor": { "name": "Test_Binary", "platform": "bayesian", + "unique_id": "3b4c9563-5e84-4167-8fe7-8f507e796d72", + "device_class": "connectivity", "observations": [ { "platform": "state", @@ -51,7 +54,14 @@ async def test_load_values_when_added_to_hass(hass): assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + entity_registry = async_get_entities(hass) + assert ( + entity_registry.entities["binary_sensor.test_binary"].unique_id + == "bayesian-3b4c9563-5e84-4167-8fe7-8f507e796d72" + ) + state = hass.states.get("binary_sensor.test_binary") + assert state.attributes.get("device_class") == "connectivity" assert state.attributes.get("observations")[0]["prob_given_true"] == 0.8 assert state.attributes.get("observations")[0]["prob_given_false"] == 0.4 diff --git a/tests/components/blebox/test_air_quality.py b/tests/components/blebox/test_air_quality.py deleted file mode 100644 index 8b5bc67d4bc0fe938bd73318e6848422252a3e9a..0000000000000000000000000000000000000000 --- a/tests/components/blebox/test_air_quality.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Blebox air_quality tests.""" -import logging -from unittest.mock import AsyncMock, PropertyMock - -import blebox_uniapi -import pytest - -from homeassistant.components.air_quality import ATTR_PM_0_1, ATTR_PM_2_5, ATTR_PM_10 -from homeassistant.const import ATTR_ICON, STATE_UNKNOWN -from homeassistant.helpers import device_registry as dr - -from .conftest import async_setup_entity, mock_feature - - -@pytest.fixture(name="airsensor") -def airsensor_fixture(): - """Return a default air quality fixture.""" - feature = mock_feature( - "air_qualities", - blebox_uniapi.air_quality.AirQuality, - unique_id="BleBox-airSensor-1afe34db9437-0.air", - full_name="airSensor-0.air", - device_class=None, - pm1=None, - pm2_5=None, - pm10=None, - ) - product = feature.product - type(product).name = PropertyMock(return_value="My air sensor") - type(product).model = PropertyMock(return_value="airSensor") - return (feature, "air_quality.airsensor_0_air") - - -async def test_init(airsensor, hass, config): - """Test airSensor default state.""" - - _, entity_id = airsensor - entry = await async_setup_entity(hass, config, entity_id) - assert entry.unique_id == "BleBox-airSensor-1afe34db9437-0.air" - - state = hass.states.get(entity_id) - assert state.name == "airSensor-0.air" - - assert ATTR_PM_0_1 not in state.attributes - assert ATTR_PM_2_5 not in state.attributes - assert ATTR_PM_10 not in state.attributes - - assert state.attributes[ATTR_ICON] == "mdi:blur" - - assert state.state == STATE_UNKNOWN - - device_registry = dr.async_get(hass) - device = device_registry.async_get(entry.device_id) - - assert device.name == "My air sensor" - assert device.identifiers == {("blebox", "abcd0123ef5678")} - assert device.manufacturer == "BleBox" - assert device.model == "airSensor" - assert device.sw_version == "1.23" - - -async def test_update(airsensor, hass, config): - """Test air quality sensor state after update.""" - - feature_mock, entity_id = airsensor - - def initial_update(): - feature_mock.pm1 = 49 - feature_mock.pm2_5 = 222 - feature_mock.pm10 = 333 - - feature_mock.async_update = AsyncMock(side_effect=initial_update) - await async_setup_entity(hass, config, entity_id) - - state = hass.states.get(entity_id) - - assert state.attributes[ATTR_PM_0_1] == 49 - assert state.attributes[ATTR_PM_2_5] == 222 - assert state.attributes[ATTR_PM_10] == 333 - - assert state.state == "222" - - -async def test_update_failure(airsensor, hass, config, caplog): - """Test that update failures are logged.""" - - caplog.set_level(logging.ERROR) - - feature_mock, entity_id = airsensor - feature_mock.async_update = AsyncMock(side_effect=blebox_uniapi.error.ClientError) - await async_setup_entity(hass, config, entity_id) - - assert f"Updating '{feature_mock.full_name}' failed: " in caplog.text diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..c9181762f3eccf685fbb5dd47fb2bf4498a1c1d0 --- /dev/null +++ b/tests/components/blebox/test_binary_sensor.py @@ -0,0 +1,46 @@ +"""Blebox binary_sensor entities test.""" +from unittest.mock import AsyncMock, PropertyMock + +import blebox_uniapi +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import async_setup_entity, mock_feature + + +@pytest.fixture(name="rainsensor") +def airsensor_fixture() -> tuple[AsyncMock, str]: + """Return a default air quality fixture.""" + feature: AsyncMock = mock_feature( + "binary_sensors", + blebox_uniapi.binary_sensor.Rain, + unique_id="BleBox-windRainSensor-ea68e74f4f49-0.rain", + full_name="windRainSensor-0.rain", + device_class="moisture", + ) + product = feature.product + type(product).name = PropertyMock(return_value="My rain sensor") + type(product).model = PropertyMock(return_value="rainSensor") + return feature, "binary_sensor.windrainsensor_0_rain" + + +async def test_init(rainsensor: AsyncMock, hass: HomeAssistant, config: dict): + """Test binary_sensor initialisation.""" + _, entity_id = rainsensor + entry = await async_setup_entity(hass, config, entity_id) + assert entry.unique_id == "BleBox-windRainSensor-ea68e74f4f49-0.rain" + + state = hass.states.get(entity_id) + assert state.name == "windRainSensor-0.rain" + + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOISTURE + assert state.state == STATE_ON + + device_registry = dr.async_get(hass) + device = device_registry.async_get(entry.device_id) + + assert device.name == "My rain sensor" diff --git a/tests/components/blebox/test_init.py b/tests/components/blebox/test_init.py index c0add2696b5745fbc9af63c1e7a74986a5ce8071..9320c3c271c86b0bc891c89855479a06db03a263 100644 --- a/tests/components/blebox/test_init.py +++ b/tests/components/blebox/test_init.py @@ -7,7 +7,7 @@ import blebox_uniapi from homeassistant.components.blebox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from .conftest import mock_config, patch_product_identify +from .conftest import mock_config, patch_product_identify, setup_product_mock async def test_setup_failure(hass, caplog): @@ -44,7 +44,7 @@ async def test_setup_failure_on_connection(hass, caplog): async def test_unload_config_entry(hass): """Test that unloading works properly.""" - patch_product_identify(None) + setup_product_mock("switches", []) entry = mock_config() entry.add_to_hass(hass) diff --git a/tests/components/blebox/test_sensor.py b/tests/components/blebox/test_sensor.py index b7f6d421a1232dbc60fbb507972e7f6480764781..d876da8b0b633e11334e8898528a3687fc919863 100644 --- a/tests/components/blebox/test_sensor.py +++ b/tests/components/blebox/test_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, STATE_UNKNOWN, TEMP_CELSIUS, ) @@ -17,9 +18,27 @@ from homeassistant.helpers import device_registry as dr from .conftest import async_setup_entity, mock_feature +@pytest.fixture(name="airsensor") +def airsensor_fixture(): + """Return a default AirQuality sensor mock.""" + feature = mock_feature( + "sensors", + blebox_uniapi.sensor.AirQuality, + unique_id="BleBox-airSensor-1afe34db9437-0.air", + full_name="airSensor-0.air", + device_class="pm1", + unit="concentration_of_mp", + native_value=None, + ) + product = feature.product + type(product).name = PropertyMock(return_value="My air sensor") + type(product).model = PropertyMock(return_value="airSensor") + return (feature, "sensor.airsensor_0_air") + + @pytest.fixture(name="tempsensor") def tempsensor_fixture(): - """Return a default sensor mock.""" + """Return a default Temperature sensor mock.""" feature = mock_feature( "sensors", blebox_uniapi.sensor.Temperature, @@ -28,6 +47,7 @@ def tempsensor_fixture(): device_class="temperature", unit="celsius", current=None, + native_value=None, ) product = feature.product type(product).name = PropertyMock(return_value="My temperature sensor") @@ -65,7 +85,7 @@ async def test_update(tempsensor, hass, config): feature_mock, entity_id = tempsensor def initial_update(): - feature_mock.current = 25.18 + feature_mock.native_value = 25.18 feature_mock.async_update = AsyncMock(side_effect=initial_update) await async_setup_entity(hass, config, entity_id) @@ -85,3 +105,46 @@ async def test_update_failure(tempsensor, hass, config, caplog): await async_setup_entity(hass, config, entity_id) assert f"Updating '{feature_mock.full_name}' failed: " in caplog.text + + +async def test_airsensor_init(airsensor, hass, config): + """Test airSensor default state.""" + + _, entity_id = airsensor + entry = await async_setup_entity(hass, config, entity_id) + assert entry.unique_id == "BleBox-airSensor-1afe34db9437-0.air" + + state = hass.states.get(entity_id) + assert state.name == "airSensor-0.air" + + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.PM1 + assert state.state == STATE_UNKNOWN + + device_registry = dr.async_get(hass) + device = device_registry.async_get(entry.device_id) + + assert device.name == "My air sensor" + assert device.identifiers == {("blebox", "abcd0123ef5678")} + assert device.manufacturer == "BleBox" + assert device.model == "airSensor" + assert device.sw_version == "1.23" + + +async def test_airsensor_update(airsensor, hass, config): + """Test air quality sensor state after update.""" + + feature_mock, entity_id = airsensor + + def initial_update(): + feature_mock.native_value = 49 + + feature_mock.async_update = AsyncMock(side_effect=initial_update) + await async_setup_entity(hass, config, entity_id) + + state = hass.states.get(entity_id) + assert ( + state.attributes[ATTR_UNIT_OF_MEASUREMENT] + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + + assert state.state == "49" diff --git a/tests/components/blueprint/test_models.py b/tests/components/blueprint/test_models.py index 02ed94709dbac536f26a1b57e40f648efa370fb0..589025a08badf03fd66cfa96f86ebcfd8872743f 100644 --- a/tests/components/blueprint/test_models.py +++ b/tests/components/blueprint/test_models.py @@ -224,7 +224,7 @@ async def test_domain_blueprints_caching(domain_bps): assert await domain_bps.async_get_blueprint("something") is obj obj_2 = object() - domain_bps.async_reset_cache() + await domain_bps.async_reset_cache() # Now we call this method again. with patch.object(domain_bps, "_load_blueprint", return_value=obj_2): diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index a836740bb9bbc38e3a73908a05b5943ec61b9536..e695f18c42f96b67ff0a4db380aca5861f73816b 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -2,6 +2,7 @@ import time +from typing import Any from unittest.mock import patch from bleak.backends.scanner import AdvertisementData, BLEDevice @@ -27,8 +28,27 @@ __all__ = ( "inject_bluetooth_service_info", "patch_all_discovered_devices", "patch_discovered_devices", + "generate_advertisement_data", ) +ADVERTISEMENT_DATA_DEFAULTS = { + "local_name": "", + "manufacturer_data": {}, + "service_data": {}, + "service_uuids": [], + "rssi": -127, + "platform_data": ((),), + "tx_power": -127, +} + + +def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: + """Generate advertisement data with defaults.""" + new = kwargs.copy() + for key, value in ADVERTISEMENT_DATA_DEFAULTS.items(): + new.setdefault(key, value) + return AdvertisementData(**new) + def _get_manager() -> BluetoothManager: """Return the bluetooth manager.""" @@ -77,7 +97,7 @@ def inject_advertisement_with_time_and_source_connectable( models.BluetoothServiceInfoBleak( name=adv.local_name or device.name or device.address, address=device.address, - rssi=device.rssi, + rssi=adv.rssi, manufacturer_data=adv.manufacturer_data, service_data=adv.service_data, service_uuids=adv.service_uuids, @@ -94,17 +114,17 @@ def inject_bluetooth_service_info_bleak( hass: HomeAssistant, info: models.BluetoothServiceInfoBleak ) -> None: """Inject an advertisement into the manager with connectable status.""" - advertisement_data = AdvertisementData( # type: ignore[no-untyped-call] + advertisement_data = generate_advertisement_data( local_name=None if info.name == "" else info.name, manufacturer_data=info.manufacturer_data, service_data=info.service_data, service_uuids=info.service_uuids, + rssi=info.rssi, ) device = BLEDevice( # type: ignore[no-untyped-call] address=info.address, name=info.name, details={}, - rssi=info.rssi, ) inject_advertisement_with_time_and_source_connectable( hass, @@ -120,17 +140,17 @@ def inject_bluetooth_service_info( hass: HomeAssistant, info: models.BluetoothServiceInfo ) -> None: """Inject a BluetoothServiceInfo into the manager.""" - advertisement_data = AdvertisementData( # type: ignore[no-untyped-call] + advertisement_data = generate_advertisement_data( # type: ignore[no-untyped-call] local_name=None if info.name == "" else info.name, manufacturer_data=info.manufacturer_data, service_data=info.service_data, service_uuids=info.service_uuids, + rssi=info.rssi, ) device = BLEDevice( # type: ignore[no-untyped-call] address=info.address, name=info.name, details={}, - rssi=info.rssi, ) inject_advertisement(hass, device, advertisement_data) @@ -138,7 +158,9 @@ def inject_bluetooth_service_info( def patch_all_discovered_devices(mock_discovered: list[BLEDevice]) -> None: """Mock all the discovered devices from all the scanners.""" return patch.object( - _get_manager(), "async_all_discovered_devices", return_value=mock_discovered + _get_manager(), + "_async_all_discovered_addresses", + return_value={ble_device.address for ble_device in mock_discovered}, ) diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py new file mode 100644 index 0000000000000000000000000000000000000000..6eb2b5a968eabd73caacf674316cf2066e4daecb --- /dev/null +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -0,0 +1,432 @@ +"""Tests for the Bluetooth integration advertisement tracking.""" + +from datetime import timedelta +import time +from unittest.mock import patch + +from bleak.backends.scanner import AdvertisementData, BLEDevice + +from homeassistant.components.bluetooth import ( + async_register_scanner, + async_track_unavailable, +) +from homeassistant.components.bluetooth.advertisement_tracker import ( + ADVERTISING_TIMES_NEEDED, +) +from homeassistant.components.bluetooth.const import ( + SOURCE_LOCAL, + UNAVAILABLE_TRACK_SECONDS, +) +from homeassistant.components.bluetooth.models import BaseHaScanner +from homeassistant.core import callback +from homeassistant.util import dt as dt_util + +from . import ( + generate_advertisement_data, + inject_advertisement_with_time_and_source, + inject_advertisement_with_time_and_source_connectable, +) + +from tests.common import async_fire_time_changed + +ONE_HOUR_SECONDS = 3600 + + +async def test_advertisment_interval_shorter_than_adapter_stack_timeout( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test we can determine the advertisement interval.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:12", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * 2), + SOURCE_LOCAL, + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, _switchbot_device_unavailable_callback, switchbot_device.address + ) + + monotonic_now = start_monotonic_time + ((ADVERTISING_TIMES_NEEDED - 1) * 2) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_than_adapter_stack_timeout_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a long advertisement interval.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:18", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + SOURCE_LOCAL, + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, _switchbot_device_unavailable_callback, switchbot_device.address + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_change_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a long advertisement interval with an adapter change.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * 2), + "original", + ) + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + "new", + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, _switchbot_device_unavailable_callback, switchbot_device.address + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_than_adapter_stack_timeout_not_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a long advertisement interval that is not connectable not reaching the advertising interval.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + SOURCE_LOCAL, + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is False + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_change_not_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a short advertisement interval with an adapter change that is not connectable.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:5C", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-100, + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + "original", + ) + + switchbot_adv_better_rssi = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-30, + ) + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv_better_rssi, + start_monotonic_time + (i * 2), + "new", + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_change_not_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a long advertisement interval with an adapter change that is not connectable.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-100, + ) + switchbot_device_went_unavailable = False + + class FakeScanner(BaseHaScanner): + """Fake scanner.""" + + @property + def discovered_devices_and_advertisement_data( + self, + ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: + """Return a list of discovered devices.""" + return {} + + scanner = FakeScanner(hass, "new") + cancel_scanner = async_register_scanner(hass, scanner, False) + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i * 2), + "original", + connectable=False, + ) + + switchbot_better_rssi_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + rssi=-30, + ) + for i in range(ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device, + switchbot_better_rssi_adv, + start_monotonic_time + (i * ONE_HOUR_SECONDS), + "new", + connectable=False, + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + ( + (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS + ) + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is False + cancel_scanner() + + # Now that the scanner is gone we should go back to the stack default timeout + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + + switchbot_device_unavailable_cancel() + + +async def test_advertisment_interval_longer_increasing_than_adapter_stack_timeout_adapter_change_not_connectable( + hass, caplog, enable_bluetooth, macos_adapter +): + """Test device with a increasing advertisement interval with an adapter change that is not connectable.""" + start_monotonic_time = time.monotonic() + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + for i in range(ADVERTISING_TIMES_NEEDED, 2 * ADVERTISING_TIMES_NEEDED): + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time + (i**2), + "new", + ) + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + UNAVAILABLE_TRACK_SECONDS + 1 + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is False + switchbot_device_unavailable_cancel() diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 4c1e8f660b327eb4d35f96dfcd9af2cadf3af1a4..69619ba76a741bb94316cda8cf98d2073e5bd39c 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -37,7 +37,6 @@ async def test_options_flow_disabled_not_setup( "id": 5, "type": "config_entries/get", "domain": "bluetooth", - "type_filter": "integration", } ) response = await ws_client.receive_json() @@ -341,7 +340,6 @@ async def test_options_flow_disabled_macos( "id": 5, "type": "config_entries/get", "domain": "bluetooth", - "type_filter": "integration", } ) response = await ws_client.receive_json() @@ -371,7 +369,6 @@ async def test_options_flow_enabled_linux( "id": 5, "type": "config_entries/get", "domain": "bluetooth", - "type_filter": "integration", } ) response = await ws_client.receive_json() diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 1da071a76abda34c84816f5b9d1ddc27ed27294f..a8d4d7aa14263de1fcf9bd45563d3abc94aa164a 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -3,12 +3,12 @@ from unittest.mock import ANY, patch -from bleak.backends.scanner import AdvertisementData, BLEDevice +from bleak.backends.scanner import BLEDevice from homeassistant.components import bluetooth from homeassistant.components.bluetooth.const import DEFAULT_ADDRESS -from . import inject_advertisement +from . import generate_advertisement_data, inject_advertisement from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -110,13 +110,18 @@ async def test_diagnostics( "sw_version": "BlueZ 4.63", }, }, + "advertisement_tracker": { + "intervals": {}, + "sources": {}, + "timings": {}, + }, "connectable_history": [], - "history": [], + "all_history": [], "scanners": [ { "adapter": "hci0", "discovered_devices": [ - {"address": "44:44:33:11:23:45", "name": "x", "rssi": -60} + {"address": "44:44:33:11:23:45", "name": "x"} ], "last_detection": ANY, "name": "hci0 (00:00:00:00:00:01)", @@ -127,7 +132,7 @@ async def test_diagnostics( { "adapter": "hci0", "discovered_devices": [ - {"address": "44:44:33:11:23:45", "name": "x", "rssi": -60} + {"address": "44:44:33:11:23:45", "name": "x"} ], "last_detection": ANY, "name": "hci0 (00:00:00:00:00:01)", @@ -138,7 +143,7 @@ async def test_diagnostics( { "adapter": "hci1", "discovered_devices": [ - {"address": "44:44:33:11:23:45", "name": "x", "rssi": -60} + {"address": "44:44:33:11:23:45", "name": "x"} ], "last_detection": ANY, "name": "hci1 (00:00:00:00:00:02)", @@ -161,7 +166,7 @@ async def test_diagnostics_macos( # error if the test is not running on linux since we won't have the correct # deps installed when testing on MacOS. switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) @@ -205,28 +210,53 @@ async def test_diagnostics_macos( "sw_version": ANY, } }, + "advertisement_tracker": { + "intervals": {}, + "sources": {"44:44:33:11:23:45": "local"}, + "timings": {"44:44:33:11:23:45": [ANY]}, + }, "connectable_history": [ { "address": "44:44:33:11:23:45", - "advertisement": ANY, + "advertisement": [ + "wohand", + {"1": {"__type": "<class " "'bytes'>", "repr": "b'\\x01'"}}, + {}, + [], + -127, + -127, + [[]], + ], "connectable": True, - "manufacturer_data": ANY, + "manufacturer_data": { + "1": {"__type": "<class " "'bytes'>", "repr": "b'\\x01'"} + }, "name": "wohand", - "rssi": 0, + "rssi": -127, "service_data": {}, "service_uuids": [], "source": "local", "time": ANY, } ], - "history": [ + "all_history": [ { "address": "44:44:33:11:23:45", - "advertisement": ANY, + "advertisement": [ + "wohand", + {"1": {"__type": "<class " "'bytes'>", "repr": "b'\\x01'"}}, + {}, + [], + -127, + -127, + [[]], + ], "connectable": True, - "manufacturer_data": ANY, + "manufacturer_data": { + "1": {"__type": "<class " "'bytes'>", "repr": "b'\\x01'"} + }, "name": "wohand", - "rssi": 0, + "rssi": -127, "service_data": {}, "service_uuids": [], "source": "local", @@ -237,7 +267,7 @@ async def test_diagnostics_macos( { "adapter": "Core Bluetooth", "discovered_devices": [ - {"address": "44:44:33:11:23:45", "name": "x", "rssi": -60} + {"address": "44:44:33:11:23:45", "name": "x"} ], "last_detection": ANY, "name": "Core Bluetooth", diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 2e311d9d97e620989e9a75b75075640cc193c094..c9a5e6c78a7e81b743486b4b0efd964ce0971943 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -44,6 +44,7 @@ from homeassistant.util import dt as dt_util from . import ( _get_manager, async_setup_with_default_adapter, + generate_advertisement_data, inject_advertisement, inject_advertisement_with_time_and_source_connectable, patch_discovered_devices, @@ -334,7 +335,9 @@ async def test_discovery_match_by_service_uuid( assert len(mock_bleak_scanner_start.mock_calls) == 1 wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + wrong_adv = generate_advertisement_data( + local_name="wrong_name", service_uuids=[] + ) inject_advertisement(hass, wrong_device, wrong_adv) await hass.async_block_till_done() @@ -342,7 +345,7 @@ async def test_discovery_match_by_service_uuid( assert len(mock_config_flow.mock_calls) == 0 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -379,7 +382,9 @@ async def test_discovery_match_by_service_uuid_connectable( assert len(mock_bleak_scanner_start.mock_calls) == 1 wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + wrong_adv = generate_advertisement_data( + local_name="wrong_name", service_uuids=[] + ) inject_advertisement_with_time_and_source_connectable( hass, wrong_device, wrong_adv, time.monotonic(), "any", True @@ -389,7 +394,7 @@ async def test_discovery_match_by_service_uuid_connectable( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -424,7 +429,9 @@ async def test_discovery_match_by_service_uuid_not_connectable( assert len(mock_bleak_scanner_start.mock_calls) == 1 wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + wrong_adv = generate_advertisement_data( + local_name="wrong_name", service_uuids=[] + ) inject_advertisement_with_time_and_source_connectable( hass, wrong_device, wrong_adv, time.monotonic(), "any", False @@ -434,7 +441,7 @@ async def test_discovery_match_by_service_uuid_not_connectable( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -467,7 +474,9 @@ async def test_discovery_match_by_name_connectable_false( assert len(mock_bleak_scanner_start.mock_calls) == 1 wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + wrong_adv = generate_advertisement_data( + local_name="wrong_name", service_uuids=[] + ) inject_advertisement_with_time_and_source_connectable( hass, wrong_device, wrong_adv, time.monotonic(), "any", False @@ -477,7 +486,7 @@ async def test_discovery_match_by_name_connectable_false( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 qingping_device = BLEDevice("44:44:33:11:23:45", "Qingping Motion & Light") - qingping_adv = AdvertisementData( + qingping_adv = generate_advertisement_data( local_name="Qingping Motion & Light", service_data={ "0000fdcd-0000-1000-8000-00805f9b34fb": b"H\x12\xcd\xd5`4-X\x08\x04\x01\xe8\x00\x00\x0f\x01{" @@ -493,8 +502,20 @@ async def test_discovery_match_by_name_connectable_false( mock_config_flow.reset_mock() # Make sure it will also take a connectable device + qingping_adv_with_better_rssi = generate_advertisement_data( + local_name="Qingping Motion & Light", + service_data={ + "0000fdcd-0000-1000-8000-00805f9b34fb": b"H\x12\xcd\xd5`4-X\x08\x04\x01\xe8\x00\x00\x0f\x02{" + }, + rssi=-30, + ) inject_advertisement_with_time_and_source_connectable( - hass, qingping_device, qingping_adv, time.monotonic(), "any", True + hass, + qingping_device, + qingping_adv_with_better_rssi, + time.monotonic(), + "any", + True, ) await hass.async_block_till_done() assert _domains_from_mock_config_flow(mock_config_flow) == ["qingping"] @@ -517,7 +538,9 @@ async def test_discovery_match_by_local_name( assert len(mock_bleak_scanner_start.mock_calls) == 1 wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + wrong_adv = generate_advertisement_data( + local_name="wrong_name", service_uuids=[] + ) inject_advertisement(hass, wrong_device, wrong_adv) await hass.async_block_till_done() @@ -525,7 +548,7 @@ async def test_discovery_match_by_local_name( assert len(mock_config_flow.mock_calls) == 0 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) @@ -559,12 +582,12 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( assert len(mock_bleak_scanner_start.mock_calls) == 1 hkc_device = BLEDevice("44:44:33:11:23:45", "lock") - hkc_adv_no_mfr_data = AdvertisementData( + hkc_adv_no_mfr_data = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={}, ) - hkc_adv = AdvertisementData( + hkc_adv = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={76: b"\x06\x02\x03\x99"}, @@ -593,7 +616,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( mock_config_flow.reset_mock() not_hkc_device = BLEDevice("44:44:33:11:23:21", "lock") - not_hkc_adv = AdvertisementData( + not_hkc_adv = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={76: b"\x02"} ) @@ -602,7 +625,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( assert len(mock_config_flow.mock_calls) == 0 not_apple_device = BLEDevice("44:44:33:11:23:23", "lock") - not_apple_adv = AdvertisementData( + not_apple_adv = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={21: b"\x02"} ) @@ -642,36 +665,38 @@ async def test_discovery_match_by_service_data_uuid_then_others( assert len(mock_bleak_scanner_start.mock_calls) == 1 device = BLEDevice("44:44:33:11:23:45", "lock") - adv_without_service_data_uuid = AdvertisementData( + adv_without_service_data_uuid = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={}, ) - adv_with_mfr_data = AdvertisementData( + adv_with_mfr_data = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={323: b"\x01\x02\x03"}, service_data={}, ) - adv_with_service_data_uuid = AdvertisementData( + adv_with_service_data_uuid = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x01\x02\x03"}, ) - adv_with_service_data_uuid_and_mfr_data = AdvertisementData( + adv_with_service_data_uuid_and_mfr_data = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={323: b"\x01\x02\x03"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x01\x02\x03"}, ) - adv_with_service_data_uuid_and_mfr_data_and_service_uuid = AdvertisementData( - local_name="lock", - manufacturer_data={323: b"\x01\x02\x03"}, - service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x01\x02\x03"}, - service_uuids=["0000fd3d-0000-1000-8000-00805f9b34fd"], + adv_with_service_data_uuid_and_mfr_data_and_service_uuid = ( + generate_advertisement_data( + local_name="lock", + manufacturer_data={323: b"\x01\x02\x03"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"\x01\x02\x03"}, + service_uuids=["0000fd3d-0000-1000-8000-00805f9b34fd"], + ) ) - adv_with_service_uuid = AdvertisementData( + adv_with_service_uuid = generate_advertisement_data( local_name="lock", manufacturer_data={}, service_data={}, @@ -790,18 +815,18 @@ async def test_discovery_match_by_service_data_uuid_when_format_changes( assert len(mock_bleak_scanner_start.mock_calls) == 1 device = BLEDevice("44:44:33:11:23:45", "lock") - adv_without_service_data_uuid = AdvertisementData( + adv_without_service_data_uuid = generate_advertisement_data( local_name="Qingping Temp RH M", service_uuids=[], manufacturer_data={}, ) - xiaomi_format_adv = AdvertisementData( + xiaomi_format_adv = generate_advertisement_data( local_name="Qingping Temp RH M", service_data={ "0000fe95-0000-1000-8000-00805f9b34fb": b"0XH\x0b\x06\xa7%\x144-X\x08" }, ) - qingping_format_adv = AdvertisementData( + qingping_format_adv = generate_advertisement_data( local_name="Qingping Temp RH M", service_data={ "0000fdcd-0000-1000-8000-00805f9b34fb": b"\x08\x16\xa7%\x144-X\x01\x04\xdb\x00\xa6\x01\x02\x01d" @@ -871,12 +896,12 @@ async def test_discovery_match_first_by_service_uuid_and_then_manufacturer_id( assert len(mock_bleak_scanner_start.mock_calls) == 1 device = BLEDevice("44:44:33:11:23:45", "lock") - adv_service_uuids = AdvertisementData( + adv_service_uuids = generate_advertisement_data( local_name="lock", service_uuids=["0000fd3d-0000-1000-8000-00805f9b34fc"], manufacturer_data={}, ) - adv_manufacturer_data = AdvertisementData( + adv_manufacturer_data = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={76: b"\x06\x02\x03\x99"}, @@ -924,10 +949,10 @@ async def test_rediscovery(hass, mock_bleak_scanner_start, enable_bluetooth): assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) - switchbot_adv_2 = AdvertisementData( + switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={1: b"\x01"}, @@ -958,8 +983,8 @@ async def test_async_discovered_device_api( with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt ), patch( - "bleak.BleakScanner.discovered_devices", # Must patch before we setup - [MagicMock(address="44:44:33:11:23:45")], + "bleak.BleakScanner.discovered_devices_and_advertisement_data", # Must patch before we setup + {"44:44:33:11:23:45": (MagicMock(address="44:44:33:11:23:45"), MagicMock())}, ): assert not bluetooth.async_discovered_service_info(hass) assert not bluetooth.async_address_present(hass, "44:44:22:22:11:22") @@ -974,10 +999,14 @@ async def test_async_discovered_device_api( assert not bluetooth.async_discovered_service_info(hass) wrong_device = BLEDevice("44:44:33:11:23:42", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + wrong_adv = generate_advertisement_data( + local_name="wrong_name", service_uuids=[] + ) inject_advertisement(hass, wrong_device, wrong_adv) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=[] + ) inject_advertisement(hass, switchbot_device, switchbot_adv) wrong_device_went_unavailable = False switchbot_device_went_unavailable = False @@ -1060,6 +1089,16 @@ async def test_register_callbacks(hass, mock_bleak_scanner_start, enable_bluetoo hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() + seen_switchbot_device = BLEDevice("44:44:33:11:23:46", "wohand") + seen_switchbot_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, + ) + + inject_advertisement(hass, seen_switchbot_device, seen_switchbot_adv) + cancel = bluetooth.async_register_callback( hass, _fake_subscriber, @@ -1070,7 +1109,7 @@ async def test_register_callbacks(hass, mock_bleak_scanner_start, enable_bluetoo assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, @@ -1080,13 +1119,13 @@ async def test_register_callbacks(hass, mock_bleak_scanner_start, enable_bluetoo inject_advertisement(hass, switchbot_device, switchbot_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() @@ -1096,7 +1135,7 @@ async def test_register_callbacks(hass, mock_bleak_scanner_start, enable_bluetoo inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() - assert len(callbacks) == 1 + assert len(callbacks) == 2 service_info: BluetoothServiceInfo = callbacks[0][0] assert service_info.name == "wohand" @@ -1138,7 +1177,7 @@ async def test_register_callbacks_raises_exception( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, @@ -1197,7 +1236,7 @@ async def test_register_callback_by_address( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, @@ -1207,13 +1246,13 @@ async def test_register_callback_by_address( inject_advertisement(hass, switchbot_device, switchbot_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") # 3rd callback raises ValueError but is still tracked inject_advertisement(hass, empty_device, empty_adv) @@ -1299,18 +1338,29 @@ async def test_register_callback_by_address_connectable_only( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - + switchbot_adv_better_rssi = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, + rssi=-30, + ) inject_advertisement_with_time_and_source_connectable( hass, switchbot_device, switchbot_adv, time.monotonic(), "test", False ) inject_advertisement_with_time_and_source_connectable( - hass, switchbot_device, switchbot_adv, time.monotonic(), "test", True + hass, + switchbot_device, + switchbot_adv_better_rssi, + time.monotonic(), + "test", + True, ) cancel() @@ -1354,7 +1404,7 @@ async def test_register_callback_by_manufacturer_id( assert len(mock_bleak_scanner_start.mock_calls) == 1 apple_device = BLEDevice("44:44:33:11:23:45", "rtx") - apple_adv = AdvertisementData( + apple_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1362,7 +1412,7 @@ async def test_register_callback_by_manufacturer_id( inject_advertisement(hass, apple_device, apple_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() @@ -1409,7 +1459,7 @@ async def test_register_callback_by_connectable( assert len(mock_bleak_scanner_start.mock_calls) == 1 apple_device = BLEDevice("44:44:33:11:23:45", "rtx") - apple_adv = AdvertisementData( + apple_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={7676: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1417,7 +1467,7 @@ async def test_register_callback_by_connectable( inject_advertisement(hass, apple_device, apple_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() @@ -1464,7 +1514,7 @@ async def test_not_filtering_wanted_apple_devices( assert len(mock_bleak_scanner_start.mock_calls) == 1 ibeacon_device = BLEDevice("44:44:33:11:23:45", "rtx") - ibeacon_adv = AdvertisementData( + ibeacon_adv = generate_advertisement_data( local_name="ibeacon", manufacturer_data={76: b"\x02\x00\x00\x00"}, ) @@ -1472,7 +1522,7 @@ async def test_not_filtering_wanted_apple_devices( inject_advertisement(hass, ibeacon_device, ibeacon_adv) homekit_device = BLEDevice("44:44:33:11:23:46", "rtx") - homekit_adv = AdvertisementData( + homekit_adv = generate_advertisement_data( local_name="homekit", manufacturer_data={76: b"\x06\x00\x00\x00"}, ) @@ -1480,7 +1530,7 @@ async def test_not_filtering_wanted_apple_devices( inject_advertisement(hass, homekit_device, homekit_adv) apple_device = BLEDevice("44:44:33:11:23:47", "rtx") - apple_adv = AdvertisementData( + apple_adv = generate_advertisement_data( local_name="apple", manufacturer_data={76: b"\x10\x00\x00\x00"}, ) @@ -1524,7 +1574,7 @@ async def test_filtering_noisy_apple_devices( assert len(mock_bleak_scanner_start.mock_calls) == 1 apple_device = BLEDevice("44:44:33:11:23:45", "rtx") - apple_adv = AdvertisementData( + apple_adv = generate_advertisement_data( local_name="noisy", manufacturer_data={76: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1532,7 +1582,7 @@ async def test_filtering_noisy_apple_devices( inject_advertisement(hass, apple_device, apple_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() @@ -1574,7 +1624,7 @@ async def test_register_callback_by_address_connectable_manufacturer_id( assert len(mock_bleak_scanner_start.mock_calls) == 1 apple_device = BLEDevice("44:44:33:11:23:45", "rtx") - apple_adv = AdvertisementData( + apple_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1628,7 +1678,7 @@ async def test_register_callback_by_manufacturer_id_and_address( assert len(mock_bleak_scanner_start.mock_calls) == 1 rtx_device = BLEDevice("44:44:33:11:23:45", "rtx") - rtx_adv = AdvertisementData( + rtx_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1636,7 +1686,7 @@ async def test_register_callback_by_manufacturer_id_and_address( inject_advertisement(hass, rtx_device, rtx_adv) yale_device = BLEDevice("44:44:33:11:23:45", "apple") - yale_adv = AdvertisementData( + yale_adv = generate_advertisement_data( local_name="yale", manufacturer_data={465: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1645,7 +1695,7 @@ async def test_register_callback_by_manufacturer_id_and_address( await hass.async_block_till_done() other_apple_device = BLEDevice("44:44:33:11:23:22", "apple") - other_apple_adv = AdvertisementData( + other_apple_adv = generate_advertisement_data( local_name="apple", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1696,7 +1746,7 @@ async def test_register_callback_by_service_uuid_and_address( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_dev = BLEDevice("44:44:33:11:23:45", "switchbot") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="switchbot", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) @@ -1704,7 +1754,7 @@ async def test_register_callback_by_service_uuid_and_address( inject_advertisement(hass, switchbot_dev, switchbot_adv) switchbot_missing_service_uuid_dev = BLEDevice("44:44:33:11:23:45", "switchbot") - switchbot_missing_service_uuid_adv = AdvertisementData( + switchbot_missing_service_uuid_adv = generate_advertisement_data( local_name="switchbot", ) @@ -1714,7 +1764,7 @@ async def test_register_callback_by_service_uuid_and_address( await hass.async_block_till_done() service_uuid_wrong_address_dev = BLEDevice("44:44:33:11:23:22", "switchbot2") - service_uuid_wrong_address_adv = AdvertisementData( + service_uuid_wrong_address_adv = generate_advertisement_data( local_name="switchbot2", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) @@ -1765,7 +1815,7 @@ async def test_register_callback_by_service_data_uuid_and_address( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_dev = BLEDevice("44:44:33:11:23:45", "switchbot") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="switchbot", service_data={"cba20d00-224d-11e6-9fb8-0002a5d5c51b": b"x"}, ) @@ -1773,7 +1823,7 @@ async def test_register_callback_by_service_data_uuid_and_address( inject_advertisement(hass, switchbot_dev, switchbot_adv) switchbot_missing_service_uuid_dev = BLEDevice("44:44:33:11:23:45", "switchbot") - switchbot_missing_service_uuid_adv = AdvertisementData( + switchbot_missing_service_uuid_adv = generate_advertisement_data( local_name="switchbot", ) @@ -1783,7 +1833,7 @@ async def test_register_callback_by_service_data_uuid_and_address( await hass.async_block_till_done() service_uuid_wrong_address_dev = BLEDevice("44:44:33:11:23:22", "switchbot2") - service_uuid_wrong_address_adv = AdvertisementData( + service_uuid_wrong_address_adv = generate_advertisement_data( local_name="switchbot2", service_data={"cba20d00-224d-11e6-9fb8-0002a5d5c51b": b"x"}, ) @@ -1831,7 +1881,7 @@ async def test_register_callback_by_local_name( assert len(mock_bleak_scanner_start.mock_calls) == 1 rtx_device = BLEDevice("44:44:33:11:23:45", "rtx") - rtx_adv = AdvertisementData( + rtx_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1839,12 +1889,12 @@ async def test_register_callback_by_local_name( inject_advertisement(hass, rtx_device, rtx_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) rtx_device_2 = BLEDevice("44:44:33:11:23:45", "rtx") - rtx_adv_2 = AdvertisementData( + rtx_adv_2 = generate_advertisement_data( local_name="rtx2", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, ) @@ -1927,7 +1977,7 @@ async def test_register_callback_by_service_data_uuid( assert len(mock_bleak_scanner_start.mock_calls) == 1 apple_device = BLEDevice("44:44:33:11:23:45", "xiaomi") - apple_adv = AdvertisementData( + apple_adv = generate_advertisement_data( local_name="xiaomi", service_data={ "0000fe95-0000-1000-8000-00805f9b34fb": b"\xd8.\xad\xcd\r\x85" @@ -1937,7 +1987,7 @@ async def test_register_callback_by_service_data_uuid( inject_advertisement(hass, apple_device, apple_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() @@ -1981,13 +2031,13 @@ async def test_register_callback_survives_reload( assert len(mock_bleak_scanner_start.mock_calls) == 1 switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["zba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - switchbot_adv_2 = AdvertisementData( + switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["zba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, @@ -2035,7 +2085,7 @@ async def test_process_advertisements_bail_on_good_advertisement( while not done.done(): device = BLEDevice("aa:44:33:11:23:45", "wohand") - adv = AdvertisementData( + adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51a"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, @@ -2060,13 +2110,13 @@ async def test_process_advertisements_ignore_bad_advertisement( return_value = asyncio.Event() device = BLEDevice("aa:44:33:11:23:45", "wohand") - adv = AdvertisementData( + adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51a"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fa": b""}, ) - adv2 = AdvertisementData( + adv2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51a"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, @@ -2142,20 +2192,20 @@ async def test_wrapped_instance_with_filter( detected.append((device, advertisement_data)) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - switchbot_adv_2 = AdvertisementData( + switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = models.HaBleakScannerWrapper( @@ -2214,20 +2264,20 @@ async def test_wrapped_instance_with_service_uuids( detected.append((device, advertisement_data)) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - switchbot_adv_2 = AdvertisementData( + switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = models.HaBleakScannerWrapper( @@ -2272,7 +2322,7 @@ async def test_wrapped_instance_with_broken_callbacks( detected.append((device, advertisement_data)) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, @@ -2313,20 +2363,20 @@ async def test_wrapped_instance_changes_uuids( detected.append((device, advertisement_data)) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - switchbot_adv_2 = AdvertisementData( + switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = models.HaBleakScannerWrapper() @@ -2368,20 +2418,20 @@ async def test_wrapped_instance_changes_filters( detected.append((device, advertisement_data)) switchbot_device = BLEDevice("44:44:33:11:23:42", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - switchbot_adv_2 = AdvertisementData( + switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = BLEDevice("11:22:33:44:55:62", "empty") - empty_adv = AdvertisementData(local_name="empty") + empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = models.HaBleakScannerWrapper() @@ -2434,8 +2484,8 @@ async def test_async_ble_device_from_address( with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt ), patch( - "bleak.BleakScanner.discovered_devices", # Must patch before we setup - [MagicMock(address="44:44:33:11:23:45")], + "bleak.BleakScanner.discovered_devices_and_advertisement_data", # Must patch before we setup + {"44:44:33:11:23:45": (MagicMock(address="44:44:33:11:23:45"), MagicMock())}, ): assert not bluetooth.async_discovered_service_info(hass) assert not bluetooth.async_address_present(hass, "44:44:22:22:11:22") @@ -2453,7 +2503,9 @@ async def test_async_ble_device_from_address( assert not bluetooth.async_discovered_service_info(hass) switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=[] + ) inject_advertisement(hass, switchbot_device, switchbot_adv) await hass.async_block_till_done() @@ -2595,7 +2647,7 @@ async def test_getting_the_scanner_returns_the_wrapped_instance(hass, enable_blu async def test_scanner_count_connectable(hass, enable_bluetooth): """Test getting the connectable scanner count.""" - scanner = models.BaseHaScanner() + scanner = models.BaseHaScanner(hass, "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=True) == 1 cancel() @@ -2603,7 +2655,7 @@ async def test_scanner_count_connectable(hass, enable_bluetooth): async def test_scanner_count(hass, enable_bluetooth): """Test getting the connectable and non-connectable scanner count.""" - scanner = models.BaseHaScanner() + scanner = models.BaseHaScanner(hass, "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=False) == 2 cancel() diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index f3f3d1b366466ae52f9b8883e9de897dfb604197..0375f68309f717b780ef91db3be082378ed47d84 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1,29 +1,57 @@ """Tests for the Bluetooth integration manager.""" +import time from unittest.mock import AsyncMock, MagicMock, patch -from bleak.backends.scanner import AdvertisementData, BLEDevice +from bleak.backends.scanner import BLEDevice from bluetooth_adapters import AdvertisementHistory +import pytest from homeassistant.components import bluetooth -from homeassistant.components.bluetooth.manager import STALE_ADVERTISEMENT_SECONDS +from homeassistant.components.bluetooth import models +from homeassistant.components.bluetooth.manager import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( + generate_advertisement_data, inject_advertisement_with_source, inject_advertisement_with_time_and_source, + inject_advertisement_with_time_and_source_connectable, ) +@pytest.fixture +def register_hci0_scanner(hass: HomeAssistant) -> None: + """Register an hci0 scanner.""" + cancel = bluetooth.async_register_scanner( + hass, models.BaseHaScanner(hass, "hci0"), True + ) + yield + cancel() + + +@pytest.fixture +def register_hci1_scanner(hass: HomeAssistant) -> None: + """Register an hci1 scanner.""" + cancel = bluetooth.async_register_scanner( + hass, models.BaseHaScanner(hass, "hci1"), True + ) + yield + cancel() + + async def test_advertisements_do_not_switch_adapters_for_no_reason( - hass, enable_bluetooth + hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner ): """Test we only switch adapters when needed.""" address = "44:44:33:11:23:12" switchbot_device_signal_100 = BLEDevice(address, "wohand_signal_100", rssi=-100) - switchbot_adv_signal_100 = AdvertisementData( + switchbot_adv_signal_100 = generate_advertisement_data( local_name="wohand_signal_100", service_uuids=[] ) inject_advertisement_with_source( @@ -36,7 +64,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( ) switchbot_device_signal_99 = BLEDevice(address, "wohand_signal_99", rssi=-99) - switchbot_adv_signal_99 = AdvertisementData( + switchbot_adv_signal_99 = generate_advertisement_data( local_name="wohand_signal_99", service_uuids=[] ) inject_advertisement_with_source( @@ -49,7 +77,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( ) switchbot_device_signal_98 = BLEDevice(address, "wohand_good_signal", rssi=-98) - switchbot_adv_signal_98 = AdvertisementData( + switchbot_adv_signal_98 = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[] ) inject_advertisement_with_source( @@ -63,14 +91,16 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( ) -async def test_switching_adapters_based_on_rssi(hass, enable_bluetooth): +async def test_switching_adapters_based_on_rssi( + hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner +): """Test switching adapters based on rssi.""" address = "44:44:33:11:23:45" - switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal", rssi=-100) - switchbot_adv_poor_signal = AdvertisementData( - local_name="wohand_poor_signal", service_uuids=[] + switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_adv_poor_signal = generate_advertisement_data( + local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" @@ -81,9 +111,9 @@ async def test_switching_adapters_based_on_rssi(hass, enable_bluetooth): is switchbot_device_poor_signal ) - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal", rssi=-60) - switchbot_adv_good_signal = AdvertisementData( - local_name="wohand_good_signal", service_uuids=[] + switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_adv_good_signal = generate_advertisement_data( + local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1" @@ -103,11 +133,9 @@ async def test_switching_adapters_based_on_rssi(hass, enable_bluetooth): ) # We should not switch adapters unless the signal hits the threshold - switchbot_device_similar_signal = BLEDevice( - address, "wohand_similar_signal", rssi=-62 - ) - switchbot_adv_similar_signal = AdvertisementData( - local_name="wohand_similar_signal", service_uuids=[] + switchbot_device_similar_signal = BLEDevice(address, "wohand_similar_signal") + switchbot_adv_similar_signal = generate_advertisement_data( + local_name="wohand_similar_signal", service_uuids=[], rssi=-62 ) inject_advertisement_with_source( @@ -119,14 +147,16 @@ async def test_switching_adapters_based_on_rssi(hass, enable_bluetooth): ) -async def test_switching_adapters_based_on_zero_rssi(hass, enable_bluetooth): +async def test_switching_adapters_based_on_zero_rssi( + hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner +): """Test switching adapters based on zero rssi.""" address = "44:44:33:11:23:45" - switchbot_device_no_rssi = BLEDevice(address, "wohand_poor_signal", rssi=0) - switchbot_adv_no_rssi = AdvertisementData( - local_name="wohand_no_rssi", service_uuids=[] + switchbot_device_no_rssi = BLEDevice(address, "wohand_poor_signal") + switchbot_adv_no_rssi = generate_advertisement_data( + local_name="wohand_no_rssi", service_uuids=[], rssi=0 ) inject_advertisement_with_source( hass, switchbot_device_no_rssi, switchbot_adv_no_rssi, "hci0" @@ -137,9 +167,9 @@ async def test_switching_adapters_based_on_zero_rssi(hass, enable_bluetooth): is switchbot_device_no_rssi ) - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal", rssi=-60) - switchbot_adv_good_signal = AdvertisementData( - local_name="wohand_good_signal", service_uuids=[] + switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_adv_good_signal = generate_advertisement_data( + local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1" @@ -159,11 +189,9 @@ async def test_switching_adapters_based_on_zero_rssi(hass, enable_bluetooth): ) # We should not switch adapters unless the signal hits the threshold - switchbot_device_similar_signal = BLEDevice( - address, "wohand_similar_signal", rssi=-62 - ) - switchbot_adv_similar_signal = AdvertisementData( - local_name="wohand_similar_signal", service_uuids=[] + switchbot_device_similar_signal = BLEDevice(address, "wohand_similar_signal") + switchbot_adv_similar_signal = generate_advertisement_data( + local_name="wohand_similar_signal", service_uuids=[], rssi=-62 ) inject_advertisement_with_source( @@ -175,17 +203,17 @@ async def test_switching_adapters_based_on_zero_rssi(hass, enable_bluetooth): ) -async def test_switching_adapters_based_on_stale(hass, enable_bluetooth): +async def test_switching_adapters_based_on_stale( + hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner +): """Test switching adapters based on the previous advertisement being stale.""" address = "44:44:33:11:23:41" start_time_monotonic = 50.0 - switchbot_device_poor_signal_hci0 = BLEDevice( - address, "wohand_poor_signal_hci0", rssi=-100 - ) - switchbot_adv_poor_signal_hci0 = AdvertisementData( - local_name="wohand_poor_signal_hci0", service_uuids=[] + switchbot_device_poor_signal_hci0 = BLEDevice(address, "wohand_poor_signal_hci0") + switchbot_adv_poor_signal_hci0 = generate_advertisement_data( + local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 ) inject_advertisement_with_time_and_source( hass, @@ -200,11 +228,9 @@ async def test_switching_adapters_based_on_stale(hass, enable_bluetooth): is switchbot_device_poor_signal_hci0 ) - switchbot_device_poor_signal_hci1 = BLEDevice( - address, "wohand_poor_signal_hci1", rssi=-99 - ) - switchbot_adv_poor_signal_hci1 = AdvertisementData( - local_name="wohand_poor_signal_hci1", service_uuids=[] + switchbot_device_poor_signal_hci1 = BLEDevice(address, "wohand_poor_signal_hci1") + switchbot_adv_poor_signal_hci1 = generate_advertisement_data( + local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99 ) inject_advertisement_with_time_and_source( hass, @@ -227,7 +253,7 @@ async def test_switching_adapters_based_on_stale(hass, enable_bluetooth): hass, switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, - start_time_monotonic + STALE_ADVERTISEMENT_SECONDS + 1, + start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1, "hci1", ) @@ -244,7 +270,7 @@ async def test_restore_history_from_dbus(hass, one_adapter): ble_device = BLEDevice(address, "name") history = { address: AdvertisementHistory( - ble_device, AdvertisementData(local_name="name"), "hci0" + ble_device, generate_advertisement_data(local_name="name"), "hci0" ) } @@ -256,3 +282,185 @@ async def test_restore_history_from_dbus(hass, one_adapter): await hass.async_block_till_done() assert bluetooth.async_ble_device_from_address(hass, address) is ble_device + + +async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( + hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner +): + """Test switching adapters based on rssi from connectable to non connectable.""" + + address = "44:44:33:11:23:45" + now = time.monotonic() + switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_adv_poor_signal = generate_advertisement_data( + local_name="wohand_poor_signal", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source_connectable( + hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_poor_signal + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, True) + is switchbot_device_poor_signal + ) + switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_adv_good_signal = generate_advertisement_data( + local_name="wohand_good_signal", service_uuids=[], rssi=-60 + ) + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device_good_signal, + switchbot_adv_good_signal, + now, + "hci1", + False, + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_good_signal + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, True) + is switchbot_device_poor_signal + ) + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device_good_signal, + switchbot_adv_poor_signal, + now, + "hci0", + False, + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_good_signal + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, True) + is switchbot_device_poor_signal + ) + switchbot_device_excellent_signal = BLEDevice(address, "wohand_excellent_signal") + switchbot_adv_excellent_signal = generate_advertisement_data( + local_name="wohand_excellent_signal", service_uuids=[], rssi=-25 + ) + + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device_excellent_signal, + switchbot_adv_excellent_signal, + now, + "hci2", + False, + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_excellent_signal + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, True) + is switchbot_device_poor_signal + ) + + +async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_connectable( + hass, enable_bluetooth, register_hci0_scanner, register_hci1_scanner +): + """Test we can still get a connectable BLEDevice when the best path is non-connectable. + + In this case the the device is closer to a non-connectable scanner, but the + at least one connectable scanner has the device in range. + """ + + address = "44:44:33:11:23:45" + now = time.monotonic() + switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_adv_good_signal = generate_advertisement_data( + local_name="wohand_good_signal", service_uuids=[], rssi=-60 + ) + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device_good_signal, + switchbot_adv_good_signal, + now, + "hci1", + False, + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_good_signal + ) + assert bluetooth.async_ble_device_from_address(hass, address, True) is None + + switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_adv_poor_signal = generate_advertisement_data( + local_name="wohand_poor_signal", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source_connectable( + hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_good_signal + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, True) + is switchbot_device_poor_signal + ) + + +async def test_switching_adapters_when_one_goes_away( + hass, enable_bluetooth, register_hci0_scanner +): + """Test switching adapters when one goes away.""" + cancel_hci2 = bluetooth.async_register_scanner( + hass, models.BaseHaScanner(hass, "hci2"), True + ) + + address = "44:44:33:11:23:45" + + switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_adv_good_signal = generate_advertisement_data( + local_name="wohand_good_signal", service_uuids=[], rssi=-60 + ) + inject_advertisement_with_source( + hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci2" + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_good_signal + ) + + switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_adv_poor_signal = generate_advertisement_data( + local_name="wohand_poor_signal", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_source( + hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + ) + + # We want to prefer the good signal when we have options + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_good_signal + ) + + cancel_hci2() + + inject_advertisement_with_source( + hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + ) + + # Now that hci2 is gone, we should prefer the poor signal + # since no poor signal is better than no signal + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal + ) diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index d126dcac301dd3573f3c817e53c83768f93662a6..adb953b2af29015880419505342e1d198f71e45c 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -16,7 +16,12 @@ from homeassistant.components.bluetooth.models import ( HaBluetoothConnector, ) -from . import _get_manager, inject_advertisement, inject_advertisement_with_source +from . import ( + _get_manager, + generate_advertisement_data, + inject_advertisement, + inject_advertisement_with_source, +) class MockBleakClient(BleakClient): @@ -49,7 +54,7 @@ async def test_wrapped_bleak_scanner(hass, enable_bluetooth): """Test wrapped bleak scanner dispatches calls as expected.""" scanner = HaBleakScannerWrapper() switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) inject_advertisement(hass, switchbot_device, switchbot_adv) @@ -84,7 +89,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( switchbot_device = BLEDevice( "44:44:33:11:23:45", "wohand", {"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"} ) - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) inject_advertisement(hass, switchbot_device, switchbot_adv) @@ -116,7 +121,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( }, rssi=-30, ) - switchbot_adv = AdvertisementData( + switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) @@ -153,6 +158,11 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab ), "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, + ) + switchbot_proxy_device_adv_no_connection_slot = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, rssi=-30, ) switchbot_proxy_device_has_connection_slot = BLEDevice( @@ -166,14 +176,19 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab }, rssi=-40, ) + switchbot_proxy_device_adv_has_connection_slot = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-40, + ) switchbot_device = BLEDevice( "44:44:33:11:23:45", "wohand", {"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"}, - rssi=-100, ) - switchbot_adv = AdvertisementData( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100 ) inject_advertisement_with_source( @@ -182,21 +197,28 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab inject_advertisement_with_source( hass, switchbot_proxy_device_has_connection_slot, - switchbot_adv, + switchbot_proxy_device_adv_has_connection_slot, "esp32_has_connection_slot", ) inject_advertisement_with_source( hass, switchbot_proxy_device_no_connection_slot, - switchbot_adv, + switchbot_proxy_device_adv_no_connection_slot, "esp32_no_connection_slot", ) class FakeScanner(BaseHaScanner): @property - def discovered_devices(self) -> list[BLEDevice]: + def discovered_devices_and_advertisement_data( + self, + ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices.""" - return [switchbot_proxy_device_has_connection_slot] + return { + switchbot_proxy_device_has_connection_slot.address: ( + switchbot_proxy_device_has_connection_slot, + switchbot_proxy_device_adv_has_connection_slot, + ) + } async def async_get_device_by_address(self, address: str) -> BLEDevice | None: """Return a list of discovered devices.""" @@ -204,7 +226,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab return switchbot_proxy_device_has_connection_slot return None - scanner = FakeScanner() + scanner = FakeScanner(hass, "esp32") cancel = manager.async_register_scanner(scanner, True) assert manager.async_discovered_devices(True) == [ switchbot_proxy_device_no_connection_slot @@ -237,7 +259,12 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab rssi=-30, ) switchbot_proxy_device_no_connection_slot.metadata["delegate"] = 0 - + switchbot_proxy_device_no_connection_slot_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-30, + ) switchbot_proxy_device_has_connection_slot = BLEDevice( "44:44:33:11:23:45", "wohand_has_connection_slot", @@ -247,9 +274,14 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab ), "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-40, ) switchbot_proxy_device_has_connection_slot.metadata["delegate"] = 0 + switchbot_proxy_device_has_connection_slot_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-40, + ) switchbot_device = BLEDevice( "44:44:33:11:23:45", @@ -258,31 +290,41 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab rssi=-100, ) switchbot_device.metadata["delegate"] = 0 - switchbot_adv = AdvertisementData( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-100, ) inject_advertisement_with_source( - hass, switchbot_device, switchbot_adv, "00:00:00:00:00:01" + hass, switchbot_device, switchbot_device_adv, "00:00:00:00:00:01" ) inject_advertisement_with_source( hass, switchbot_proxy_device_has_connection_slot, - switchbot_adv, + switchbot_proxy_device_has_connection_slot_adv, "esp32_has_connection_slot", ) inject_advertisement_with_source( hass, switchbot_proxy_device_no_connection_slot, - switchbot_adv, + switchbot_proxy_device_no_connection_slot_adv, "esp32_no_connection_slot", ) class FakeScanner(BaseHaScanner): @property - def discovered_devices(self) -> list[BLEDevice]: + def discovered_devices_and_advertisement_data( + self, + ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices.""" - return [switchbot_proxy_device_has_connection_slot] + return { + switchbot_proxy_device_has_connection_slot.address: ( + switchbot_proxy_device_has_connection_slot, + switchbot_proxy_device_has_connection_slot_adv, + ) + } async def async_get_device_by_address(self, address: str) -> BLEDevice | None: """Return a list of discovered devices.""" @@ -290,7 +332,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab return switchbot_proxy_device_has_connection_slot return None - scanner = FakeScanner() + scanner = FakeScanner(hass, "esp32") cancel = manager.async_register_scanner(scanner, True) assert manager.async_discovered_devices(True) == [ switchbot_proxy_device_no_connection_slot diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index b8ade8c39f9af7afd78d6d0284e796671d1e70c9..fb80bb7cec4f99e872b50ba5e5dfc659d5a27a6a 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -127,8 +127,8 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( ): """Test that the coordinator goes unavailable when the bluetooth stack no longer sees the device.""" with patch( - "bleak.BleakScanner.discovered_devices", # Must patch before we setup - [MagicMock(address="44:44:33:11:23:45")], + "bleak.BleakScanner.discovered_devices_and_advertisement_data", # Must patch before we setup + {"44:44:33:11:23:45": (MagicMock(address="44:44:33:11:23:45"), MagicMock())}, ): await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 0ca5f299a50b62245f37cf106656be3d0188cf0c..e72efd565deb0203e0fe7f5694ca4e935d4b63d5 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -201,8 +201,8 @@ async def test_unavailable_after_no_data( ): """Test that the coordinator is unavailable after no data for a while.""" with patch( - "bleak.BleakScanner.discovered_devices", # Must patch before we setup - [MagicMock(address="44:44:33:11:23:45")], + "bleak.BleakScanner.discovered_devices_and_advertisement_data", # Must patch before we setup + {"44:44:33:11:23:45": (MagicMock(address="44:44:33:11:23:45"), MagicMock())}, ): await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index a46663524791629a0ccb7571eee33f0c7463500d..c3a08ac3361b1bb950c3ef206d86f99c5a475f41 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -1,14 +1,11 @@ """Tests for the Bluetooth integration scanners.""" +import asyncio from datetime import timedelta import time from unittest.mock import MagicMock, patch from bleak import BleakError -from bleak.backends.scanner import ( - AdvertisementData, - AdvertisementDataCallback, - BLEDevice, -) +from bleak.backends.scanner import AdvertisementDataCallback, BLEDevice from dbus_fast import InvalidMessageError import pytest @@ -22,7 +19,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.util import dt as dt_util -from . import _get_manager, async_setup_with_one_adapter +from . import _get_manager, async_setup_with_one_adapter, generate_advertisement_data from tests.common import async_fire_time_changed @@ -222,7 +219,7 @@ async def test_recovery_from_dbus_restart(hass, one_adapter): ): _callback( BLEDevice("44:44:33:11:23:42", "any_name"), - AdvertisementData(local_name="any_name"), + generate_advertisement_data(local_name="any_name"), ) # Ensure we don't restart the scanner if we don't need to @@ -491,3 +488,68 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( assert len(mock_recover_adapter.mock_calls) == 1 assert "Waiting for adapter to initialize" in caplog.text + + +async def test_restart_takes_longer_than_watchdog_time(hass, one_adapter, caplog): + """Test we do not try to recover the adapter again if the restart is still in progress.""" + + release_start_event = asyncio.Event() + called_start = 0 + + class MockBleakScanner: + async def start(self, *args, **kwargs): + """Mock Start.""" + nonlocal called_start + called_start += 1 + if called_start == 1: + return + await release_start_event.wait() + + async def stop(self, *args, **kwargs): + """Mock Start.""" + + @property + def discovered_devices(self): + """Mock discovered_devices.""" + return [] + + def register_detection_callback(self, callback: AdvertisementDataCallback): + """Mock Register Detection Callback.""" + + scanner = MockBleakScanner() + start_time_monotonic = time.monotonic() + + with patch( + "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + 0, + ), patch( + "homeassistant.components.bluetooth.scanner.MONOTONIC_TIME", + return_value=start_time_monotonic, + ), patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + return_value=scanner, + ), patch( + "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + ): + await async_setup_with_one_adapter(hass) + + assert called_start == 1 + + # Now force a recover adapter 2x + for _ in range(2): + with patch( + "homeassistant.components.bluetooth.scanner.MONOTONIC_TIME", + return_value=start_time_monotonic + + SCANNER_WATCHDOG_TIMEOUT + + SCANNER_WATCHDOG_INTERVAL.total_seconds(), + ): + async_fire_time_changed( + hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL + ) + await asyncio.sleep(0) + + # Now release the start event + release_start_event.set() + await hass.async_block_till_done() + + assert "already restarting" in caplog.text diff --git a/tests/components/bluetooth_le_tracker/test_device_tracker.py b/tests/components/bluetooth_le_tracker/test_device_tracker.py index 36ed6abdde5a93cb42b08344c658ce670429669e..585c83f20a7747fa551278934ad19bd6f24f15b2 100644 --- a/tests/components/bluetooth_le_tracker/test_device_tracker.py +++ b/tests/components/bluetooth_le_tracker/test_device_tracker.py @@ -5,7 +5,7 @@ from datetime import timedelta from unittest.mock import patch from bleak import BleakError -from bleak.backends.scanner import AdvertisementData, BLEDevice +from bleak.backends.scanner import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth_le_tracker import device_tracker @@ -23,6 +23,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from tests.common import async_fire_time_changed +from tests.components.bluetooth import generate_advertisement_data class MockBleakClient: @@ -89,7 +90,7 @@ async def test_preserve_new_tracked_device_name( service_uuids=[], source="local", device=BLEDevice(address, None), - advertisement=AdvertisementData(local_name="empty"), + advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, ) @@ -114,7 +115,7 @@ async def test_preserve_new_tracked_device_name( service_uuids=[], source="local", device=BLEDevice(address, None), - advertisement=AdvertisementData(local_name="empty"), + advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, ) @@ -158,7 +159,7 @@ async def test_tracking_battery_times_out( service_uuids=[], source="local", device=BLEDevice(address, None), - advertisement=AdvertisementData(local_name="empty"), + advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, ) @@ -224,7 +225,7 @@ async def test_tracking_battery_fails(hass, mock_bluetooth, mock_device_tracker_ service_uuids=[], source="local", device=BLEDevice(address, None), - advertisement=AdvertisementData(local_name="empty"), + advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, ) @@ -292,7 +293,7 @@ async def test_tracking_battery_successful( service_uuids=[], source="local", device=BLEDevice(address, None), - advertisement=AdvertisementData(local_name="empty"), + advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=True, ) diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py index 90b3f725a6dc6d05ecb3e22b2c9fbc2dfece7746..f1b0011656f09c53ad88f97278acef7695f50668 100644 --- a/tests/components/bmw_connected_drive/test_sensor.py +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -3,8 +3,8 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.util.unit_system import ( - IMPERIAL_SYSTEM as IMPERIAL, METRIC_SYSTEM as METRIC, + US_CUSTOMARY_SYSTEM as IMPERIAL, UnitSystem, ) diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 64986e9d973f71f5e876db2616585e2756d63927..58e684a1378a0e799ec654d2ae2496bef0789682 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -1,17 +1,27 @@ """Define tests for the Bravia TV config flow.""" from unittest.mock import patch -from pybravia import BraviaTVAuthError, BraviaTVConnectionError, BraviaTVNotSupported +from pybravia import ( + BraviaTVAuthError, + BraviaTVConnectionError, + BraviaTVError, + BraviaTVNotSupported, +) +import pytest from homeassistant import data_entry_flow from homeassistant.components import ssdp from homeassistant.components.braviatv.const import ( + CONF_CLIENT_ID, CONF_IGNORED_SOURCES, + CONF_NICKNAME, CONF_USE_PSK, DOMAIN, + NICKNAME_PREFIX, ) -from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.helpers import instance_id from tests.common import MockConfigEntry @@ -87,6 +97,7 @@ async def test_show_form(hass): async def test_ssdp_discovery(hass): """Test that the device is discovered.""" + uuid = await instance_id.async_get(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, @@ -123,6 +134,8 @@ async def test_ssdp_discovery(hass): CONF_PIN: "1234", CONF_USE_PSK: False, CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_CLIENT_ID: uuid, + CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}", } @@ -222,12 +235,13 @@ async def test_authorize_model_unsupported(hass): async def test_authorize_no_ip_control(hass): """Test that errors are shown when IP Control is disabled on the TV.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} - ) + with patch("pybravia.BraviaTV.pair", side_effect=BraviaTVError): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "no_ip_control" + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "no_ip_control" async def test_duplicate_error(hass): @@ -263,6 +277,8 @@ async def test_duplicate_error(hass): async def test_create_entry(hass): """Test that the user step works.""" + uuid = await instance_id.async_get(hass) + with patch("pybravia.BraviaTV.connect"), patch("pybravia.BraviaTV.pair"), patch( "pybravia.BraviaTV.set_wol_mode" ), patch( @@ -290,11 +306,15 @@ async def test_create_entry(hass): CONF_PIN: "1234", CONF_USE_PSK: False, CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_CLIENT_ID: uuid, + CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}", } async def test_create_entry_with_ipv6_address(hass): """Test that the user step works with device IPv6 address.""" + uuid = await instance_id.async_get(hass) + with patch("pybravia.BraviaTV.connect"), patch("pybravia.BraviaTV.pair"), patch( "pybravia.BraviaTV.set_wol_mode" ), patch( @@ -324,6 +344,8 @@ async def test_create_entry_with_ipv6_address(hass): CONF_PIN: "1234", CONF_USE_PSK: False, CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_CLIENT_ID: uuid, + CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}", } @@ -398,3 +420,110 @@ async def test_options_flow(hass): assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert config_entry.options == {CONF_IGNORED_SOURCES: ["HDMI 1", "HDMI 2"]} + + +@pytest.mark.parametrize( + "user_input", + [{CONF_PIN: "mypsk", CONF_USE_PSK: True}, {CONF_PIN: "1234", CONF_USE_PSK: False}], +) +async def test_reauth_successful(hass, user_input): + """Test starting a reauthentication flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="very_unique_string", + data={ + CONF_HOST: "bravia-host", + CONF_PIN: "1234", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + }, + title="TV-Model", + ) + config_entry.add_to_hass(hass) + + with patch("pybravia.BraviaTV.connect"), patch( + "pybravia.BraviaTV.get_power_status", + return_value="active", + ), patch( + "pybravia.BraviaTV.get_external_status", + return_value=BRAVIA_SOURCES, + ), patch( + "pybravia.BraviaTV.send_rest_req", + return_value={}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": config_entry.entry_id}, + data=config_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_unsuccessful(hass): + """Test reauthentication flow failed.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="very_unique_string", + data={ + CONF_HOST: "bravia-host", + CONF_PIN: "1234", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + }, + title="TV-Model", + ) + config_entry.add_to_hass(hass) + + with patch( + "pybravia.BraviaTV.connect", + side_effect=BraviaTVAuthError, + ), patch("pybravia.BraviaTV.pair"): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": config_entry.entry_id}, + data=config_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "mypsk", CONF_USE_PSK: True}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reauth_unsuccessful" + + +async def test_reauth_unsuccessful_during_pairing(hass): + """Test reauthentication flow failed because of pairing error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="very_unique_string", + data={ + CONF_HOST: "bravia-host", + CONF_PIN: "1234", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + }, + title="TV-Model", + ) + config_entry.add_to_hass(hass) + + with patch("pybravia.BraviaTV.pair", side_effect=BraviaTVError): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": config_entry.entry_id}, + data=config_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reauth_unsuccessful" diff --git a/tests/components/broadlink/__init__.py b/tests/components/broadlink/__init__.py index ce7e79bdff6420e896587b1a8f8dfd57412db823..8cdb4f478a3d9921fa6bc383ed3b8d11f965de6f 100644 --- a/tests/components/broadlink/__init__.py +++ b/tests/components/broadlink/__init__.py @@ -78,6 +78,16 @@ BROADLINK_DEVICES = { 57, 5, ), + "Gaming room": ( + "192.168.0.65", + "34ea34b61d2d", + "MP1-1K4S", + "Broadlink", + "MP1", + 0x4EB5, + 57, + 5, + ), } diff --git a/tests/components/broadlink/test_device.py b/tests/components/broadlink/test_device.py index 5430af9e311f6c7ac1a21deb0ff5cdb1d5573176..c50494a9b840e80bd173528ce572a0c74092d383 100644 --- a/tests/components/broadlink/test_device.py +++ b/tests/components/broadlink/test_device.py @@ -6,6 +6,7 @@ import broadlink.exceptions as blke from homeassistant.components.broadlink.const import DOMAIN from homeassistant.components.broadlink.device import get_domains from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.helpers.entity_registry import async_entries_for_device from . import get_device @@ -266,7 +267,11 @@ async def test_device_setup_registry(hass): assert device_entry.sw_version == device.fwversion for entry in async_entries_for_device(entity_registry, device_entry.id): - assert entry.original_name.startswith(device.name) + assert ( + hass.states.get(entry.entity_id) + .attributes[ATTR_FRIENDLY_NAME] + .startswith(device.name) + ) async def test_device_unload_works(hass): @@ -345,4 +350,8 @@ async def test_device_update_listener(hass): ) assert device_entry.name == "New Name" for entry in async_entries_for_device(entity_registry, device_entry.id): - assert entry.original_name.startswith("New Name") + assert ( + hass.states.get(entry.entity_id) + .attributes[ATTR_FRIENDLY_NAME] + .startswith("New Name") + ) diff --git a/tests/components/broadlink/test_remote.py b/tests/components/broadlink/test_remote.py index a3b291efd0021bfc1b7e8ceb55407fcd8eebc36a..d4fd9cd75b49ef96e699cb3842249108b8fff1b4 100644 --- a/tests/components/broadlink/test_remote.py +++ b/tests/components/broadlink/test_remote.py @@ -9,7 +9,7 @@ from homeassistant.components.remote import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON, Platform from homeassistant.helpers.entity_registry import async_entries_for_device from . import get_device @@ -39,7 +39,10 @@ async def test_remote_setup_works(hass): assert len(remotes) == 1 remote = remotes[0] - assert remote.original_name == f"{device.name} Remote" + assert ( + hass.states.get(remote.entity_id).attributes[ATTR_FRIENDLY_NAME] + == device.name + ) assert hass.states.get(remote.entity_id).state == STATE_ON assert mock_setup.api.auth.call_count == 1 diff --git a/tests/components/broadlink/test_sensors.py b/tests/components/broadlink/test_sensors.py index b5a49fdae15e6b007c507c518ae0aa6e608ecaff..13190883ef0978167222031f3233d8cac9e295d6 100644 --- a/tests/components/broadlink/test_sensors.py +++ b/tests/components/broadlink/test_sensors.py @@ -3,7 +3,7 @@ from datetime import timedelta from homeassistant.components.broadlink.const import DOMAIN from homeassistant.components.broadlink.updater import BroadlinkSP4UpdateManager -from homeassistant.const import Platform +from homeassistant.const import ATTR_FRIENDLY_NAME, Platform from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.util import dt @@ -39,13 +39,16 @@ async def test_a1_sensor_setup(hass): assert len(sensors) == 5 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == { (f"{device.name} Temperature", "27.4"), (f"{device.name} Humidity", "59.3"), - (f"{device.name} Air Quality", "3"), + (f"{device.name} Air quality", "3"), (f"{device.name} Light", "2"), (f"{device.name} Noise", "1"), } @@ -86,13 +89,16 @@ async def test_a1_sensor_update(hass): assert mock_setup.api.check_sensors_raw.call_count == 2 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == { (f"{device.name} Temperature", "22.5"), (f"{device.name} Humidity", "47.4"), - (f"{device.name} Air Quality", "2"), + (f"{device.name} Air quality", "2"), (f"{device.name} Light", "3"), (f"{device.name} Noise", "2"), } @@ -118,7 +124,10 @@ async def test_rm_pro_sensor_setup(hass): assert len(sensors) == 1 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == {(f"{device.name} Temperature", "18.2")} @@ -147,7 +156,10 @@ async def test_rm_pro_sensor_update(hass): assert mock_setup.api.check_sensors.call_count == 2 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == {(f"{device.name} Temperature", "25.8")} @@ -179,7 +191,10 @@ async def test_rm_pro_filter_crazy_temperature(hass): assert mock_setup.api.check_sensors.call_count == 2 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == {(f"{device.name} Temperature", "22.9")} @@ -225,7 +240,10 @@ async def test_rm4_pro_hts2_sensor_setup(hass): assert len(sensors) == 2 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == { @@ -257,7 +275,10 @@ async def test_rm4_pro_hts2_sensor_update(hass): assert mock_setup.api.check_sensors.call_count == 2 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == { @@ -316,7 +337,10 @@ async def test_scb1e_sensor_setup(hass): assert len(sensors) == 5 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == { @@ -378,7 +402,10 @@ async def test_scb1e_sensor_update(hass): assert mock_setup.api.get_state.call_count == 2 sensors_and_states = { - (sensor.original_name, hass.states.get(sensor.entity_id).state) + ( + hass.states.get(sensor.entity_id).attributes[ATTR_FRIENDLY_NAME], + hass.states.get(sensor.entity_id).state, + ) for sensor in sensors } assert sensors_and_states == { diff --git a/tests/components/broadlink/test_switch.py b/tests/components/broadlink/test_switch.py new file mode 100644 index 0000000000000000000000000000000000000000..9a7fc7e1ec9b7fe7e44db7954ed89c339c97d0c7 --- /dev/null +++ b/tests/components/broadlink/test_switch.py @@ -0,0 +1,126 @@ +"""Tests for Broadlink switches.""" +from homeassistant.components.broadlink.const import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON, Platform +from homeassistant.helpers.entity_registry import async_entries_for_device + +from . import get_device + +from tests.common import mock_device_registry, mock_registry + + +async def test_switch_setup_works(hass): + """Test a successful setup with a switch.""" + device = get_device("Dining room") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + mock_setup = await device.setup_entry(hass) + + device_entry = device_registry.async_get_device( + {(DOMAIN, mock_setup.entry.unique_id)} + ) + entries = async_entries_for_device(entity_registry, device_entry.id) + switches = [entry for entry in entries if entry.domain == Platform.SWITCH] + assert len(switches) == 1 + + switch = switches[0] + assert ( + hass.states.get(switch.entity_id).attributes[ATTR_FRIENDLY_NAME] == device.name + ) + assert hass.states.get(switch.entity_id).state == STATE_OFF + assert mock_setup.api.auth.call_count == 1 + + +async def test_switch_turn_off_turn_on(hass): + """Test send turn on and off for a switch.""" + device = get_device("Dining room") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + mock_setup = await device.setup_entry(hass) + + device_entry = device_registry.async_get_device( + {(DOMAIN, mock_setup.entry.unique_id)} + ) + entries = async_entries_for_device(entity_registry, device_entry.id) + switches = [entry for entry in entries if entry.domain == Platform.SWITCH] + assert len(switches) == 1 + + switch = switches[0] + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": switch.entity_id}, + blocking=True, + ) + assert hass.states.get(switch.entity_id).state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": switch.entity_id}, + blocking=True, + ) + assert hass.states.get(switch.entity_id).state == STATE_ON + + assert mock_setup.api.auth.call_count == 1 + + +async def test_slots_switch_setup_works(hass): + """Test a successful setup with a switch with slots.""" + device = get_device("Gaming room") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + mock_setup = await device.setup_entry(hass) + + device_entry = device_registry.async_get_device( + {(DOMAIN, mock_setup.entry.unique_id)} + ) + entries = async_entries_for_device(entity_registry, device_entry.id) + switches = [entry for entry in entries if entry.domain == Platform.SWITCH] + assert len(switches) == 4 + + for slot, switch in enumerate(switches): + assert ( + hass.states.get(switch.entity_id).attributes[ATTR_FRIENDLY_NAME] + == f"{device.name} S{slot+1}" + ) + assert hass.states.get(switch.entity_id).state == STATE_OFF + assert mock_setup.api.auth.call_count == 1 + + +async def test_slots_switch_turn_off_turn_on(hass): + """Test send turn on and off for a switch with slots.""" + device = get_device("Gaming room") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + mock_setup = await device.setup_entry(hass) + + device_entry = device_registry.async_get_device( + {(DOMAIN, mock_setup.entry.unique_id)} + ) + entries = async_entries_for_device(entity_registry, device_entry.id) + switches = [entry for entry in entries if entry.domain == Platform.SWITCH] + assert len(switches) == 4 + + for switch in switches: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": switch.entity_id}, + blocking=True, + ) + assert hass.states.get(switch.entity_id).state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": switch.entity_id}, + blocking=True, + ) + assert hass.states.get(switch.entity_id).state == STATE_ON + + assert mock_setup.api.auth.call_count == 1 diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index 9212e12e5b3dfbe3f35b0939d9c6d642cf5862d1..58ccecaf29fe349164837ce0f93acae714701ca2 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -113,8 +113,6 @@ async def test_sensors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.hl_l2340dw_drum_remaining_life") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" - assert state.attributes.get(ATTR_REMAINING_PAGES) == 11014 - assert state.attributes.get(ATTR_COUNTER) == 986 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -123,11 +121,31 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_drum_remaining_life" + state = hass.states.get("sensor.hl_l2340dw_drum_remaining_pages") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "11014" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_drum_remaining_pages") + assert entry + assert entry.unique_id == "0123456789_drum_remaining_pages" + + state = hass.states.get("sensor.hl_l2340dw_drum_counter") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "986" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_drum_counter") + assert entry + assert entry.unique_id == "0123456789_drum_counter" + state = hass.states.get("sensor.hl_l2340dw_black_drum_remaining_life") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" - assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389 - assert state.attributes.get(ATTR_COUNTER) == 1611 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -136,11 +154,31 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_black_drum_remaining_life" + state = hass.states.get("sensor.hl_l2340dw_black_drum_remaining_pages") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "16389" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_black_drum_remaining_pages") + assert entry + assert entry.unique_id == "0123456789_black_drum_remaining_pages" + + state = hass.states.get("sensor.hl_l2340dw_black_drum_counter") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "1611" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_black_drum_counter") + assert entry + assert entry.unique_id == "0123456789_black_drum_counter" + state = hass.states.get("sensor.hl_l2340dw_cyan_drum_remaining_life") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" - assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389 - assert state.attributes.get(ATTR_COUNTER) == 1611 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -149,11 +187,31 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_cyan_drum_remaining_life" + state = hass.states.get("sensor.hl_l2340dw_cyan_drum_remaining_pages") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "16389" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_pages") + assert entry + assert entry.unique_id == "0123456789_cyan_drum_remaining_pages" + + state = hass.states.get("sensor.hl_l2340dw_cyan_drum_counter") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "1611" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_counter") + assert entry + assert entry.unique_id == "0123456789_cyan_drum_counter" + state = hass.states.get("sensor.hl_l2340dw_magenta_drum_remaining_life") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" - assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389 - assert state.attributes.get(ATTR_COUNTER) == 1611 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -162,11 +220,31 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_magenta_drum_remaining_life" + state = hass.states.get("sensor.hl_l2340dw_magenta_drum_remaining_pages") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "16389" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_pages") + assert entry + assert entry.unique_id == "0123456789_magenta_drum_remaining_pages" + + state = hass.states.get("sensor.hl_l2340dw_magenta_drum_counter") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "1611" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_counter") + assert entry + assert entry.unique_id == "0123456789_magenta_drum_counter" + state = hass.states.get("sensor.hl_l2340dw_yellow_drum_remaining_life") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" - assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389 - assert state.attributes.get(ATTR_COUNTER) == 1611 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -175,6 +253,28 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_yellow_drum_remaining_life" + state = hass.states.get("sensor.hl_l2340dw_yellow_drum_remaining_pages") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "16389" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_pages") + assert entry + assert entry.unique_id == "0123456789_yellow_drum_remaining_pages" + + state = hass.states.get("sensor.hl_l2340dw_yellow_drum_counter") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES + assert state.state == "1611" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_counter") + assert entry + assert entry.unique_id == "0123456789_yellow_drum_counter" + state = hass.states.get("sensor.hl_l2340dw_fuser_remaining_life") assert state assert state.attributes.get(ATTR_ICON) == "mdi:water-outline" diff --git a/tests/components/bsblan/__init__.py b/tests/components/bsblan/__init__.py index f2e88d97ba2e160cdb194156129155a0548da4ed..d233fa068ea95e591fba8c9393901ab9c9b9677e 100644 --- a/tests/components/bsblan/__init__.py +++ b/tests/components/bsblan/__init__.py @@ -1,88 +1 @@ """Tests for the bsblan integration.""" - -from homeassistant.components.bsblan.const import ( - CONF_DEVICE_IDENT, - CONF_PASSKEY, - DOMAIN, -) -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONTENT_TYPE_JSON, -) -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry, load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker - - -async def init_integration( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - skip_setup: bool = False, -) -> MockConfigEntry: - """Set up the BSBLan integration in Home Assistant.""" - - aioclient_mock.post( - "http://example.local:80/1234/JQ?Parameter=6224,6225,6226", - params={"Parameter": "6224,6225,6226"}, - text=load_fixture("bsblan/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="RVS21.831F/127", - data={ - CONF_HOST: "example.local", - CONF_USERNAME: "nobody", - CONF_PASSWORD: "qwerty", - CONF_PASSKEY: "1234", - CONF_PORT: 80, - CONF_DEVICE_IDENT: "RVS21.831F/127", - }, - ) - - entry.add_to_hass(hass) - - if not skip_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return entry - - -async def init_integration_without_auth( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - skip_setup: bool = False, -) -> MockConfigEntry: - """Set up the BSBLan integration in Home Assistant.""" - - aioclient_mock.post( - "http://example.local:80/1234/JQ?Parameter=6224,6225,6226", - params={"Parameter": "6224,6225,6226"}, - text=load_fixture("bsblan/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="RVS21.831F/127", - data={ - CONF_HOST: "example.local", - CONF_PASSKEY: "1234", - CONF_PORT: 80, - CONF_DEVICE_IDENT: "RVS21.831F/127", - }, - ) - - entry.add_to_hass(hass) - - if not skip_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return entry diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..44d87745b3f101a9e33bad97e9e729106a855daf --- /dev/null +++ b/tests/components/bsblan/conftest.py @@ -0,0 +1,79 @@ +"""Fixtures for BSBLAN integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from bsblan import Device, Info, State +import pytest + +from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="BSBLAN Setup", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.bsblan.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_bsblan_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked BSBLAN client.""" + with patch( + "homeassistant.components.bsblan.config_flow.BSBLAN", autospec=True + ) as bsblan_mock: + bsblan = bsblan_mock.return_value + bsblan.device.return_value = Device.parse_raw( + load_fixture("device.json", DOMAIN) + ) + bsblan.info.return_value = Info.parse_raw(load_fixture("info.json", DOMAIN)) + yield bsblan + + +@pytest.fixture +def mock_bsblan(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: + """Return a mocked BSBLAN client.""" + + with patch("homeassistant.components.bsblan.BSBLAN", autospec=True) as bsblan_mock: + bsblan = bsblan_mock.return_value + bsblan.info.return_value = Info.parse_raw(load_fixture("info.json", DOMAIN)) + bsblan.device.return_value = Device.parse_raw( + load_fixture("device.json", DOMAIN) + ) + bsblan.state.return_value = State.parse_raw(load_fixture("state.json", DOMAIN)) + yield bsblan + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_bsblan: MagicMock +) -> MockConfigEntry: + """Set up the bsblan integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/bsblan/fixtures/device.json b/tests/components/bsblan/fixtures/device.json new file mode 100644 index 0000000000000000000000000000000000000000..10543d722538204274d7fb4b09c1013ba916168d --- /dev/null +++ b/tests/components/bsblan/fixtures/device.json @@ -0,0 +1,42 @@ +{ + "name": "BSB-LAN", + "version": "1.0.38-20200730234859", + "freeram": 85479, + "uptime": 969402857, + "MAC": "00:80:41:19:69:90", + "freespace": 0, + "bus": "BSB", + "buswritable": 1, + "busaddr": 66, + "busdest": 0, + "monitor": 0, + "verbose": 1, + "protectedGPIO": [ + { "pin": 0 }, + { "pin": 1 }, + { "pin": 4 }, + { "pin": 10 }, + { "pin": 11 }, + { "pin": 12 }, + { "pin": 13 }, + { "pin": 18 }, + { "pin": 19 }, + { "pin": 20 }, + { "pin": 21 }, + { "pin": 22 }, + { "pin": 23 }, + { "pin": 50 }, + { "pin": 51 }, + { "pin": 52 }, + { "pin": 53 }, + { "pin": 62 }, + { "pin": 63 }, + { "pin": 64 }, + { "pin": 65 }, + { "pin": 66 }, + { "pin": 67 }, + { "pin": 68 }, + { "pin": 69 } + ], + "averages": [] +} diff --git a/tests/components/bsblan/fixtures/info.json b/tests/components/bsblan/fixtures/info.json index 08ae7e462472f10f69800554a0a2eb8df60a82e1..556c7463e173c8b9cf4251a8ac150c2dcb79161a 100644 --- a/tests/components/bsblan/fixtures/info.json +++ b/tests/components/bsblan/fixtures/info.json @@ -1,23 +1,29 @@ { - "6224": { - "name": "Geräte-Identifikation", + "device_identification": { + "name": "Gerte-Identifikation", + "error": 0, "value": "RVS21.831F/127", - "unit": "", "desc": "", - "dataType": 7 + "dataType": 7, + "readonly": 0, + "unit": "" }, - "6225": { + "controller_family": { "name": "Device family", + "error": 0, "value": "211", - "unit": "", "desc": "", - "dataType": 0 + "dataType": 0, + "readonly": 0, + "unit": "" }, - "6226": { + "controller_variant": { "name": "Device variant", + "error": 0, "value": "127", - "unit": "", "desc": "", - "dataType": 0 + "dataType": 0, + "readonly": 0, + "unit": "" } } diff --git a/tests/components/bsblan/fixtures/state.json b/tests/components/bsblan/fixtures/state.json new file mode 100644 index 0000000000000000000000000000000000000000..51d4cf2e136eeb1be115735da7663b8678810e8f --- /dev/null +++ b/tests/components/bsblan/fixtures/state.json @@ -0,0 +1,101 @@ +{ + "hvac_mode": { + "name": "Operating mode", + "error": 0, + "value": "heat", + "desc": "Komfort", + "dataType": 1, + "readonly": 0, + "unit": "" + }, + "target_temperature": { + "name": "Room temperature Comfort setpoint", + "error": 0, + "value": "18.5", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "target_temperature_high": { + "name": "Komfortsollwert Maximum", + "error": 0, + "value": "23.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "target_temperature_low": { + "name": "Room temp reduced setpoint", + "error": 0, + "value": "17.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "min_temp": { + "name": "Room temp frost protection setpoint", + "error": 0, + "value": "8.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "max_temp": { + "name": "Summer/winter changeover temp heat circuit 1", + "error": 0, + "value": "20.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "hvac_mode2": { + "name": "Operating mode", + "error": 0, + "value": "2", + "desc": "Reduziert", + "dataType": 1, + "readonly": 0, + "unit": "" + }, + "hvac_action": { + "name": "Status heating circuit 1", + "error": 0, + "value": "122", + "desc": "Raumtemp\u2019begrenzung", + "dataType": 1, + "readonly": 1, + "unit": "" + }, + "outside_temperature": { + "name": "Outside temp sensor local", + "error": 0, + "value": "6.1", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "current_temperature": { + "name": "Room temp 1 actual value", + "error": 0, + "value": "18.6", + "desc": "", + "dataType": 0, + "readonly": 1, + "unit": "°C" + }, + "room1_thermostat_mode": { + "name": "Raumthermostat 1", + "error": 0, + "value": "0", + "desc": "Kein Bedarf", + "dataType": 1, + "readonly": 1, + "unit": "" + } +} diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index b8efa960fcabd9b3ce441d8673cc69ab8ecb9587..a1286f436952c719c8ca4fb37c1ead2b373e21e3 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -1,159 +1,119 @@ """Tests for the BSBLan device config flow.""" -import aiohttp +from unittest.mock import AsyncMock, MagicMock + +from bsblan import BSBLANConnectionError from homeassistant import data_entry_flow from homeassistant.components.bsblan import config_flow -from homeassistant.components.bsblan.const import CONF_DEVICE_IDENT, CONF_PASSKEY +from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONTENT_TYPE_JSON, -) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.helpers.device_registry import format_mac -from . import init_integration - -from tests.common import load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry -async def test_show_user_form(hass: HomeAssistant) -> None: - """Test that the user set up form is served.""" +async def test_full_user_flow_implementation( + hass: HomeAssistant, + mock_bsblan_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( - config_flow.DOMAIN, + DOMAIN, context={"source": SOURCE_USER}, ) - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.FlowResultType.FORM - - -async def test_connection_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test we show user form on BSBLan connection error.""" - aioclient_mock.post( - "http://example.local:80/1234/JQ?Parameter=6224,6225,6226", - exc=aiohttp.ClientError, - ) + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": SOURCE_USER}, - data={ - CONF_HOST: "example.local", - CONF_USERNAME: "nobody", - CONF_PASSWORD: "qwerty", - CONF_PASSKEY: "1234", + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", }, ) - assert result["errors"] == {"base": "cannot_connect"} - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == format_mac("00:80:41:19:69:90") + assert result2.get("data") == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + } + assert "result" in result2 + assert result2["result"].unique_id == format_mac("00:80:41:19:69:90") + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_bsblan_config_flow.device.mock_calls) == 1 -async def test_user_device_exists_abort( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test we abort zeroconf flow if BSBLan device already configured.""" - await init_integration(hass, aioclient_mock) +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the user set up form is served.""" result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": SOURCE_USER}, - data={ - CONF_HOST: "example.local", - CONF_USERNAME: "nobody", - CONF_PASSWORD: "qwerty", - CONF_PASSKEY: "1234", - CONF_PORT: 80, - }, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM -async def test_full_user_flow_implementation( - hass: HomeAssistant, aioclient_mock +async def test_connection_error( + hass: HomeAssistant, + mock_bsblan_config_flow: MagicMock, ) -> None: - """Test the full manual user flow from start to finish.""" - aioclient_mock.post( - "http://example.local:80/1234/JQ?Parameter=6224,6225,6226", - text=load_fixture("bsblan/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) + """Test we show user form on BSBLan connection error.""" + mock_bsblan_config_flow.device.side_effect = BSBLANConnectionError result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, context={"source": SOURCE_USER}, - ) - - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "example.local", - CONF_USERNAME: "nobody", - CONF_PASSWORD: "qwerty", - CONF_PASSKEY: "1234", + data={ + CONF_HOST: "127.0.0.1", CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", }, ) - assert result["data"][CONF_HOST] == "example.local" - assert result["data"][CONF_USERNAME] == "nobody" - assert result["data"][CONF_PASSWORD] == "qwerty" - assert result["data"][CONF_PASSKEY] == "1234" - assert result["data"][CONF_PORT] == 80 - assert result["data"][CONF_DEVICE_IDENT] == "RVS21.831F/127" - assert result["title"] == "RVS21.831F/127" - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {"base": "cannot_connect"} + assert result.get("step_id") == "user" - entries = hass.config_entries.async_entries(config_flow.DOMAIN) - assert entries[0].unique_id == "RVS21.831F/127" - -async def test_full_user_flow_implementation_without_auth( - hass: HomeAssistant, aioclient_mock +async def test_user_device_exists_abort( + hass: HomeAssistant, + mock_bsblan_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the full manual user flow from start to finish.""" - aioclient_mock.post( - "http://example2.local:80/JQ?Parameter=6224,6225,6226", - text=load_fixture("bsblan/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - + """Test we abort flow if BSBLAN device already configured.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, context={"source": SOURCE_USER}, - ) - - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "example2.local", + data={ + CONF_HOST: "127.0.0.1", CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", }, ) - assert result["data"][CONF_HOST] == "example2.local" - assert result["data"][CONF_USERNAME] is None - assert result["data"][CONF_PASSWORD] is None - assert result["data"][CONF_PASSKEY] is None - assert result["data"][CONF_PORT] == 80 - assert result["data"][CONF_DEVICE_IDENT] == "RVS21.831F/127" - assert result["title"] == "RVS21.831F/127" - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - entries = hass.config_entries.async_entries(config_flow.DOMAIN) - assert entries[0].unique_id == "RVS21.831F/127" + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index 147ba46cb5b5c3da25e034535a50918e99a78970..34ee30a35e18050c868dd863feefd5026c3bd41d 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -1,48 +1,46 @@ """Tests for the BSBLan integration.""" -import aiohttp +from unittest.mock import MagicMock + +from bsblan import BSBLANConnectionError from homeassistant.components.bsblan.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import init_integration, init_integration_without_auth - -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry -async def test_config_entry_not_ready( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, ) -> None: - """Test the BSBLan configuration entry not ready.""" - aioclient_mock.post( - "http://example.local:80/1234/JQ?Parameter=6224,6225,6226", - exc=aiohttp.ClientError, - ) - - entry = await init_integration(hass, aioclient_mock) - assert entry.state is ConfigEntryState.SETUP_RETRY - + """Test the BSBLAN configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() -async def test_unload_config_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test the BSBLan configuration entry unloading.""" - entry = await init_integration(hass, aioclient_mock) - assert hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_bsblan.device.mock_calls) == 1 - await hass.config_entries.async_unload(entry.entry_id) + await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -async def test_config_entry_no_authentication( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, ) -> None: - """Test the BSBLan configuration entry not ready.""" - aioclient_mock.post( - "http://example.local:80/1234/JQ?Parameter=6224,6225,6226", - exc=aiohttp.ClientError, - ) - - entry = await init_integration_without_auth(hass, aioclient_mock) - assert entry.state is ConfigEntryState.SETUP_RETRY + """Test the bsblan configuration entry not ready.""" + mock_bsblan.state.side_effect = BSBLANConnectionError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_bsblan.state.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/bthome/__init__.py b/tests/components/bthome/__init__.py index e480c0a38103c1f641765350713c826124812a30..25ccb72edfad257360c1b8eac973be2c4856490b 100644 --- a/tests/components/bthome/__init__.py +++ b/tests/components/bthome/__init__.py @@ -1,10 +1,11 @@ """Tests for the BTHome integration.""" from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from tests.components.bluetooth import generate_advertisement_data + TEMP_HUMI_SERVICE_INFO = BluetoothServiceInfoBleak( name="ATC 8D18B2", address="A4:C1:38:8D:18:B2", @@ -16,7 +17,7 @@ TEMP_HUMI_SERVICE_INFO = BluetoothServiceInfoBleak( }, service_uuids=["0000181c-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -32,7 +33,7 @@ TEMP_HUMI_ENCRYPTED_SERVICE_INFO = BluetoothServiceInfoBleak( }, service_uuids=["0000181e-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -48,7 +49,7 @@ PRST_SERVICE_INFO = BluetoothServiceInfoBleak( }, service_uuids=["0000181c-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="prst"), + advertisement=generate_advertisement_data(local_name="prst"), time=0, connectable=False, ) @@ -64,7 +65,7 @@ INVALID_PAYLOAD = BluetoothServiceInfoBleak( }, service_uuids=["0000181c-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -78,7 +79,7 @@ NOT_BTHOME_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={}, service_uuids=[], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -97,7 +98,7 @@ def make_advertisement(address: str, payload: bytes) -> BluetoothServiceInfoBlea }, service_uuids=["0000181c-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Test Device"), + advertisement=generate_advertisement_data(local_name="Test Device"), time=0, connectable=False, ) @@ -118,7 +119,7 @@ def make_encrypted_advertisement( }, service_uuids=["0000181e-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="ATC 8F80A5"), + advertisement=generate_advertisement_data(local_name="ATC 8F80A5"), time=0, connectable=False, ) diff --git a/tests/components/calendar/test_recorder.py b/tests/components/calendar/test_recorder.py index 0fbcaf38432232d8c2781191ab851c349b6d6dc1..85e32155723a9ad9d2fbbf5671d4465ec731a7bf 100644 --- a/tests/components/calendar/test_recorder.py +++ b/tests/components/calendar/test_recorder.py @@ -13,7 +13,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_events_http_api(hass, recorder_mock): +async def test_events_http_api(recorder_mock, hass): """Test the calendar demo view.""" await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index ebe5e9185e407646a1a88201da29d3cc21d2b990..24b4b06b49396070a550694e7b82cd9e770c1880 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -62,14 +62,15 @@ class FakeSchedule: self, start: datetime.timedelta, end: datetime.timedelta, - description: str = None, - location: str = None, + summary: str | None = None, + description: str | None = None, + location: str | None = None, ) -> dict[str, Any]: """Create a new fake event, used by tests.""" event = calendar.CalendarEvent( start=start, end=end, - summary=f"Event {secrets.token_hex(16)}", # Arbitrary unique data + summary=summary if summary else f"Event {secrets.token_hex(16)}", description=description, location=location, ) @@ -85,8 +86,13 @@ class FakeSchedule: """Get all events in a specific time frame, used by the demo calendar.""" assert start_date < end_date values = [] + local_start_date = dt_util.as_local(start_date) + local_end_date = dt_util.as_local(end_date) for event in self.events: - if start_date < event.start < end_date or start_date < event.end < end_date: + if ( + event.start_datetime_local < local_end_date + and local_start_date < event.end_datetime_local + ): values.append(event) return values @@ -99,11 +105,28 @@ class FakeSchedule: async def fire_until(self, end: datetime.timedelta) -> None: """Simulate the passage of time by firing alarms until the time is reached.""" + + current_time = dt_util.as_utc(self.freezer()) + if (end - current_time) > (TEST_UPDATE_INTERVAL * 2): + # Jump ahead to right before the target alarm them to remove + # unnecessary waiting, before advancing in smaller increments below. + # This leaves time for multiple update intervals to refresh the set + # of upcoming events + await self.fire_time(end - TEST_UPDATE_INTERVAL * 2) + while dt_util.utcnow() < end: self.freezer.tick(TEST_TIME_ADVANCE_INTERVAL) await self.fire_time(dt_util.utcnow()) +@pytest.fixture +def set_time_zone(hass): + """Set the time zone for the tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + hass.config.set_time_zone("America/Regina") + + @pytest.fixture def fake_schedule(hass, freezer): """Fixture that tests can use to make fake events.""" @@ -534,25 +557,72 @@ async def test_update_missed(hass, calls, fake_schedule): ] -async def test_event_payload(hass, calls, fake_schedule): - """Test the a calendar trigger based on start time.""" - event_data = fake_schedule.create_event( - start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), - end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), - description="Description", - location="Location", - ) +@pytest.mark.parametrize( + "create_data,fire_time,payload_data", + [ + ( + { + "start": datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + "end": datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), + "summary": "Summary", + }, + datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), + { + "summary": "Summary", + "start": "2022-04-19T11:00:00+00:00", + "end": "2022-04-19T11:30:00+00:00", + "all_day": False, + }, + ), + ( + { + "start": datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + "end": datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), + "summary": "Summary", + "description": "Description", + "location": "Location", + }, + datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), + { + "summary": "Summary", + "start": "2022-04-19T11:00:00+00:00", + "end": "2022-04-19T11:30:00+00:00", + "all_day": False, + "description": "Description", + "location": "Location", + }, + ), + ( + { + "summary": "Summary", + "start": datetime.date.fromisoformat("2022-04-20"), + "end": datetime.date.fromisoformat("2022-04-21"), + }, + datetime.datetime.fromisoformat("2022-04-20 00:00:01-06:00"), + { + "summary": "Summary", + "start": "2022-04-20", + "end": "2022-04-21", + "all_day": True, + }, + ), + ], + ids=["basic", "more-fields", "all-day"], +) +async def test_event_payload( + hass, calls, fake_schedule, set_time_zone, create_data, fire_time, payload_data +): + """Test the fields in the calendar event payload are set.""" + fake_schedule.create_event(**create_data) await create_automation(hass, EVENT_START) assert len(calls()) == 0 - await fake_schedule.fire_until( - datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00") - ) + await fake_schedule.fire_until(fire_time) assert calls() == [ { "platform": "calendar", "event": EVENT_START, - "calendar_event": event_data, + "calendar_event": payload_data, } ] diff --git a/tests/components/camera/test_recorder.py b/tests/components/camera/test_recorder.py index 1217997a9968f89d3aff2f3fbadf4cd18c542ed7..3417399d729ea01811502e1f6859ec0ff291ab1a 100644 --- a/tests/components/camera/test_recorder.py +++ b/tests/components/camera/test_recorder.py @@ -20,7 +20,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test camera registered attributes to be excluded.""" await async_setup_component( hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/clicksend_tts/__init__.py b/tests/components/clicksend_tts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c822773ef709d7bc83a82d841f703175c91901a4 --- /dev/null +++ b/tests/components/clicksend_tts/__init__.py @@ -0,0 +1 @@ +"""Tests for the ClickSend TTS component.""" diff --git a/tests/components/clicksend_tts/test_notify.py b/tests/components/clicksend_tts/test_notify.py new file mode 100644 index 0000000000000000000000000000000000000000..9bebb3cfbcad27da143fa8dfcd3bf005c7a71286 --- /dev/null +++ b/tests/components/clicksend_tts/test_notify.py @@ -0,0 +1,122 @@ +"""The test for the Facebook notify module.""" +import base64 +from http import HTTPStatus +import logging +from unittest.mock import patch + +import pytest +import requests_mock + +from homeassistant.components import notify +import homeassistant.components.clicksend_tts.notify as cs_tts +from homeassistant.setup import async_setup_component + +from tests.common import assert_setup_component + +# Infos from https://developers.clicksend.com/docs/rest/v3/#testing +TEST_USERNAME = "nocredit" +TEST_API_KEY = "D83DED51-9E35-4D42-9BB9-0E34B7CA85AE" +TEST_VOICE_NUMBER = "+61411111111" + +TEST_VOICE = "male" +TEST_LANGUAGE = "fr-fr" +TEST_MESSAGE = "Just a test message!" + + +CONFIG = { + notify.DOMAIN: { + "platform": "clicksend_tts", + cs_tts.CONF_USERNAME: TEST_USERNAME, + cs_tts.CONF_API_KEY: TEST_API_KEY, + cs_tts.CONF_RECIPIENT: TEST_VOICE_NUMBER, + cs_tts.CONF_LANGUAGE: TEST_LANGUAGE, + cs_tts.CONF_VOICE: TEST_VOICE, + } +} + + +@pytest.fixture +def mock_clicksend_tts_notify(): + """Mock Clicksend TTS notify service.""" + with patch( + "homeassistant.components.clicksend_tts.notify.get_service", autospec=True + ) as ns: + yield ns + + +async def setup_notify(hass): + """Test setup.""" + with assert_setup_component(1, notify.DOMAIN) as config: + assert await async_setup_component(hass, notify.DOMAIN, CONFIG) + assert config[notify.DOMAIN] + await hass.async_block_till_done() + + +async def test_no_notify_service(hass, mock_clicksend_tts_notify, caplog): + """Test missing platform notify service instance.""" + caplog.set_level(logging.ERROR) + mock_clicksend_tts_notify.return_value = None + await setup_notify(hass) + await hass.async_block_till_done() + assert mock_clicksend_tts_notify.called + assert "Failed to initialize notification service clicksend_tts" in caplog.text + + +async def test_send_simple_message(hass): + """Test sending a simple message with success.""" + + with requests_mock.Mocker() as mock: + # Mocking authentication endpoint + mock.get( + f"{cs_tts.BASE_API_URL}/account", + status_code=HTTPStatus.OK, + ) + + # Mocking TTS endpoint + mock.post( + f"{cs_tts.BASE_API_URL}/voice/send", + status_code=HTTPStatus.OK, + ) + + # Setting up integration + await setup_notify(hass) + + # Sending message + data = { + notify.ATTR_MESSAGE: TEST_MESSAGE, + } + await hass.services.async_call( + notify.DOMAIN, cs_tts.DEFAULT_NAME, data, blocking=True + ) + + # Checking if everything went well + assert mock.called + assert mock.call_count == 2 + + expected_body = { + "messages": [ + { + "source": "hass.notify", + "to": TEST_VOICE_NUMBER, + "body": TEST_MESSAGE, + "lang": TEST_LANGUAGE, + "voice": TEST_VOICE, + } + ] + } + assert mock.last_request.json() == expected_body + + expected_content_type = "application/json" + assert ( + "Content-Type" in mock.last_request.headers.keys() + and mock.last_request.headers["Content-Type"] == expected_content_type + ) + + encoded_auth = base64.b64encode( + f"{TEST_USERNAME}:{TEST_API_KEY}".encode() + ).decode() + expected_auth = f"Basic {encoded_auth}" + assert ( + "Authorization" in mock.last_request.headers + and mock.last_request.headers["Authorization"] == expected_auth + ) diff --git a/tests/components/climate/test_recorder.py b/tests/components/climate/test_recorder.py index bf254d2c02fb66764c80786ed2d6c918c1b0b0f7..be3a0f22856e4dce808b60f54a1b84394e43bb7b 100644 --- a/tests/components/climate/test_recorder.py +++ b/tests/components/climate/test_recorder.py @@ -26,7 +26,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test climate registered attributes to be excluded.""" await async_setup_component( hass, climate.DOMAIN, {climate.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index b4927ca1b66a9bdc945ece2eb61b9b0903cdf963..80d394c38ef18711634de11601c7cf2004636d31 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -10,7 +10,6 @@ from homeassistant.components.coinbase.const import ( CONF_CURRENCIES, CONF_EXCHANGE_PRECISION, CONF_EXCHANGE_RATES, - CONF_YAML_API_TOKEN, DOMAIN, ) from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN @@ -23,8 +22,6 @@ from .common import ( ) from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHANGE_RATE -from tests.common import MockConfigEntry - async def test_form(hass): """Test we get the form.""" @@ -44,8 +41,6 @@ async def test_form(hass): "coinbase.wallet.client.Client.get_exchange_rates", return_value=mock_get_exchange_rates(), ), patch( - "homeassistant.components.coinbase.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.coinbase.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -61,7 +56,6 @@ async def test_form(hass): assert result2["type"] == "create_entry" assert result2["title"] == "Test User" assert result2["data"] == {CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"} - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -303,62 +297,3 @@ async def test_option_catch_all_exception(hass): assert result2["type"] == "form" assert result2["errors"] == {"base": "unknown"} - - -async def test_yaml_import(hass): - """Test YAML import works.""" - conf = { - CONF_API_KEY: "123456", - CONF_YAML_API_TOKEN: "AbCDeF", - CONF_CURRENCIES: ["BTC", "USD"], - CONF_EXCHANGE_RATES: ["ATOM", "BTC"], - } - with patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), - ), patch( - "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts - ), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), - ), patch( - "homeassistant.components.coinbase.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.coinbase.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 - ) - assert result["type"] == "create_entry" - assert result["title"] == "Test User" - assert result["data"] == {CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"} - assert result["options"] == { - CONF_CURRENCIES: ["BTC", "USD"], - CONF_EXCHANGE_RATES: ["ATOM", "BTC"], - } - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_yaml_existing(hass): - """Test YAML ignored when already processed.""" - MockConfigEntry( - domain=DOMAIN, - data={ - CONF_API_KEY: "123456", - CONF_API_TOKEN: "AbCDeF", - }, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "123456", - CONF_YAML_API_TOKEN: "AbCDeF", - }, - ) - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" diff --git a/tests/components/coinbase/test_init.py b/tests/components/coinbase/test_init.py index efb5ba85f731f1f523dd54bda3e168abadd3604d..4f8538a54460e5d4ff3ea3605a2dd642035669d0 100644 --- a/tests/components/coinbase/test_init.py +++ b/tests/components/coinbase/test_init.py @@ -6,13 +6,10 @@ from homeassistant.components.coinbase.const import ( API_TYPE_VAULT, CONF_CURRENCIES, CONF_EXCHANGE_RATES, - CONF_YAML_API_TOKEN, DOMAIN, ) -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry -from homeassistant.setup import async_setup_component from .common import ( init_mock_coinbase, @@ -28,37 +25,6 @@ from .const import ( ) -async def test_setup(hass): - """Test setting up from configuration.yaml.""" - conf = { - DOMAIN: { - CONF_API_KEY: "123456", - CONF_YAML_API_TOKEN: "AbCDeF", - CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], - CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2], - } - } - with patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), - ), patch( - "coinbase.wallet.client.Client.get_accounts", - new=mocked_get_accounts, - ), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), - ): - assert await async_setup_component(hass, DOMAIN, conf) - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].title == "Test User" - assert entries[0].source == config_entries.SOURCE_IMPORT - assert entries[0].options == { - CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], - CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE_2], - } - - async def test_unload_entry(hass): """Test successful unload of entry.""" with patch( diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index d36f7282bdbd0c53e7da12e189155dde19555ab5..29a2395a92619abfca2e93128c7feaf1c3bd3b6b 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -51,7 +51,15 @@ async def test_get_entries(hass, client, clear_handlers): mock_integration( hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) ) - mock_integration(hass, MockModule("comp3")) + mock_integration( + hass, MockModule("comp3", partial_manifest={"integration_type": "hub"}) + ) + mock_integration( + hass, MockModule("comp4", partial_manifest={"integration_type": "device"}) + ) + mock_integration( + hass, MockModule("comp5", partial_manifest={"integration_type": "service"}) + ) @HANDLERS.register("comp1") class Comp1ConfigFlow: @@ -91,6 +99,16 @@ async def test_get_entries(hass, client, clear_handlers): source="bla3", disabled_by=core_ce.ConfigEntryDisabler.USER, ).add_to_hass(hass) + MockConfigEntry( + domain="comp4", + title="Test 4", + source="bla4", + ).add_to_hass(hass) + MockConfigEntry( + domain="comp5", + title="Test 5", + source="bla5", + ).add_to_hass(hass) resp = await client.get("/api/config/config_entries/entry") assert resp.status == HTTPStatus.OK @@ -137,6 +155,32 @@ async def test_get_entries(hass, client, clear_handlers): "disabled_by": core_ce.ConfigEntryDisabler.USER, "reason": None, }, + { + "domain": "comp4", + "title": "Test 4", + "source": "bla4", + "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "disabled_by": None, + "reason": None, + }, + { + "domain": "comp5", + "title": "Test 5", + "source": "bla5", + "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "disabled_by": None, + "reason": None, + }, ] resp = await client.get("/api/config/config_entries/entry?domain=comp3") @@ -150,19 +194,24 @@ async def test_get_entries(hass, client, clear_handlers): data = await resp.json() assert len(data) == 0 - resp = await client.get( - "/api/config/config_entries/entry?domain=comp3&type=integration" - ) + resp = await client.get("/api/config/config_entries/entry?type=hub") + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert len(data) == 2 + assert data[0]["domain"] == "comp1" + assert data[1]["domain"] == "comp3" + + resp = await client.get("/api/config/config_entries/entry?type=device") assert resp.status == HTTPStatus.OK data = await resp.json() assert len(data) == 1 + assert data[0]["domain"] == "comp4" - resp = await client.get("/api/config/config_entries/entry?type=integration") + resp = await client.get("/api/config/config_entries/entry?type=service") assert resp.status == HTTPStatus.OK data = await resp.json() - assert len(data) == 2 - assert data[0]["domain"] == "comp1" - assert data[1]["domain"] == "comp3" + assert len(data) == 1 + assert data[0]["domain"] == "comp5" async def test_remove_entry(hass, client): @@ -1123,7 +1172,16 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): mock_integration( hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) ) - mock_integration(hass, MockModule("comp3")) + mock_integration( + hass, MockModule("comp3", partial_manifest={"integration_type": "hub"}) + ) + mock_integration( + hass, MockModule("comp4", partial_manifest={"integration_type": "device"}) + ) + mock_integration( + hass, MockModule("comp5", partial_manifest={"integration_type": "service"}) + ) + entry = MockConfigEntry( domain="comp1", title="Test 1", @@ -1143,6 +1201,16 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): source="bla3", disabled_by=core_ce.ConfigEntryDisabler.USER, ).add_to_hass(hass) + MockConfigEntry( + domain="comp4", + title="Test 4", + source="bla4", + ).add_to_hass(hass) + MockConfigEntry( + domain="comp5", + title="Test 5", + source="bla5", + ).add_to_hass(hass) ws_client = await hass_ws_client(hass) @@ -1197,6 +1265,34 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): "supports_unload": False, "title": "Test 3", }, + { + "disabled_by": None, + "domain": "comp4", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla4", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 4", + }, + { + "disabled_by": None, + "domain": "comp5", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla5", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 5", + }, ] await ws_client.send_json( @@ -1204,7 +1300,7 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): "id": 6, "type": "config_entries/get", "domain": "comp1", - "type_filter": "integration", + "type_filter": "hub", } ) response = await ws_client.receive_json() @@ -1225,22 +1321,102 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): "title": "Test 1", } ] - # Verify we skip broken integrations + await ws_client.send_json( + { + "id": 7, + "type": "config_entries/get", + "type_filter": ["service", "device"], + } + ) + response = await ws_client.receive_json() + assert response["id"] == 7 + assert response["result"] == [ + { + "disabled_by": None, + "domain": "comp4", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla4", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 4", + }, + { + "disabled_by": None, + "domain": "comp5", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla5", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 5", + }, + ] + + await ws_client.send_json( + { + "id": 8, + "type": "config_entries/get", + "type_filter": "hub", + } + ) + response = await ws_client.receive_json() + assert response["id"] == 8 + 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": "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 skip broken integrations with patch( "homeassistant.components.config.config_entries.async_get_integration", side_effect=IntegrationNotFound("any"), ): await ws_client.send_json( { - "id": 7, + "id": 9, "type": "config_entries/get", - "type_filter": "integration", + "type_filter": "hub", } ) response = await ws_client.receive_json() - assert response["id"] == 7 + assert response["id"] == 9 assert response["result"] == [ { "disabled_by": None, @@ -1284,8 +1460,53 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): "supports_unload": False, "title": "Test 3", }, + { + "disabled_by": None, + "domain": "comp4", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla4", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 4", + }, + { + "disabled_by": None, + "domain": "comp5", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla5", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 5", + }, ] + # Verify we don't send config entries when only helpers are requested + with patch( + "homeassistant.components.config.config_entries.async_get_integration", + side_effect=IntegrationNotFound("any"), + ): + await ws_client.send_json( + { + "id": 10, + "type": "config_entries/get", + "type_filter": ["helper"], + } + ) + response = await ws_client.receive_json() + + assert response["id"] == 10 + assert response["result"] == [] + # Verify we raise if something really goes wrong with patch( @@ -1294,14 +1515,14 @@ async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): ): await ws_client.send_json( { - "id": 8, + "id": 11, "type": "config_entries/get", - "type_filter": "integration", + "type_filter": ["device", "hub", "service"], } ) response = await ws_client.receive_json() - assert response["id"] == 8 + assert response["id"] == 11 assert response["success"] is False @@ -1312,7 +1533,9 @@ async def test_subscribe_entries_ws(hass, hass_ws_client, clear_handlers): mock_integration( hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) ) - mock_integration(hass, MockModule("comp3")) + mock_integration( + hass, MockModule("comp3", partial_manifest={"integration_type": "device"}) + ) entry = MockConfigEntry( domain="comp1", title="Test 1", @@ -1476,7 +1699,12 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler mock_integration( hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) ) - mock_integration(hass, MockModule("comp3")) + mock_integration( + hass, MockModule("comp3", partial_manifest={"integration_type": "device"}) + ) + mock_integration( + hass, MockModule("comp4", partial_manifest={"integration_type": "service"}) + ) entry = MockConfigEntry( domain="comp1", title="Test 1", @@ -1491,12 +1719,19 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler reason="Unsupported API", ) entry2.add_to_hass(hass) - MockConfigEntry( + entry3 = MockConfigEntry( domain="comp3", title="Test 3", source="bla3", disabled_by=core_ce.ConfigEntryDisabler.USER, - ).add_to_hass(hass) + ) + entry3.add_to_hass(hass) + entry4 = MockConfigEntry( + domain="comp4", + title="Test 4", + source="bla4", + ) + entry4.add_to_hass(hass) ws_client = await hass_ws_client(hass) @@ -1504,7 +1739,7 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler { "id": 5, "type": "config_entries/subscribe", - "type_filter": "integration", + "type_filter": ["hub", "device"], } ) response = await ws_client.receive_json() @@ -1551,6 +1786,8 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler }, ] assert hass.config_entries.async_update_entry(entry, title="changed") + assert hass.config_entries.async_update_entry(entry3, title="changed too") + assert hass.config_entries.async_update_entry(entry4, title="changed but ignored") response = await ws_client.receive_json() assert response["id"] == 5 assert response["event"] == [ @@ -1572,6 +1809,27 @@ async def test_subscribe_entries_ws_filtered(hass, hass_ws_client, clear_handler "type": "updated", } ] + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["event"] == [ + { + "entry": { + "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": "changed too", + }, + "type": "updated", + } + ] await hass.config_entries.async_remove(entry.entry_id) await hass.config_entries.async_remove(entry2.entry_id) response = await ws_client.receive_json() diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 2f4cd980d8e1f153b401f1ef82fb25c7e2829657..ee46838b01c6cbf4b47ad7876e8cdfb32d54f7d5 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -345,7 +345,7 @@ async def test_update_entity(hass, client): "platform": "test_platform", "unique_id": "1234", }, - "reload_delay": 30, + "require_restart": True, } # UPDATE ENTITY OPTION diff --git a/tests/components/darksky/test_sensor.py b/tests/components/darksky/test_sensor.py index 02bc392cd6803d3105825b04e590838e549efcb5..3d1f8fb26bc9d855b45e0f9f97963c6c3a49f341 100644 --- a/tests/components/darksky/test_sensor.py +++ b/tests/components/darksky/test_sensor.py @@ -1,17 +1,14 @@ """The tests for the Dark Sky platform.""" from datetime import timedelta import re -import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import patch import forecastio -from requests.exceptions import HTTPError -import requests_mock +from requests.exceptions import ConnectionError as ConnectError -from homeassistant.components.darksky import sensor as darksky -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component -from tests.common import get_test_home_assistant, load_fixture +from tests.common import load_fixture VALID_CONFIG_MINIMAL = { "sensor": { @@ -69,140 +66,100 @@ INVALID_CONFIG_LANG = { } } -VALID_CONFIG_ALERTS = { - "sensor": { - "platform": "darksky", - "api_key": "foo", - "forecast": [1, 2], - "hourly_forecast": [1, 2], - "monitored_conditions": ["summary", "icon", "temperature_high", "alerts"], - "scan_interval": timedelta(seconds=120), - } -} +async def test_setup_with_config(hass, requests_mock): + """Test the platform setup with configuration.""" + with patch("homeassistant.components.darksky.sensor.forecastio.load_forecast"): + assert await async_setup_component(hass, "sensor", VALID_CONFIG_MINIMAL) + await hass.async_block_till_done() -def load_forecastMock(key, lat, lon, units, lang): # pylint: disable=invalid-name - """Mock darksky forecast loading.""" - return "" + state = hass.states.get("sensor.dark_sky_summary") + assert state is not None -class TestDarkSkySetup(unittest.TestCase): - """Test the Dark Sky platform.""" +async def test_setup_with_invalid_config(hass): + """Test the platform setup with invalid configuration.""" + assert await async_setup_component(hass, "sensor", INVALID_CONFIG_MINIMAL) + await hass.async_block_till_done() - def add_entities(self, new_entities, update_before_add=False): - """Mock add entities.""" - if update_before_add: - for entity in new_entities: - entity.update() + state = hass.states.get("sensor.dark_sky_summary") + assert state is None - for entity in new_entities: - self.entities.append(entity) - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() - self.key = "foo" - self.lat = self.hass.config.latitude = 37.8267 - self.lon = self.hass.config.longitude = -122.423 - self.entities = [] - self.addCleanup(self.tear_down_cleanup) +async def test_setup_with_language_config(hass): + """Test the platform setup with language configuration.""" + with patch("homeassistant.components.darksky.sensor.forecastio.load_forecast"): + assert await async_setup_component(hass, "sensor", VALID_CONFIG_LANG_DE) + await hass.async_block_till_done() - def tear_down_cleanup(self): - """Stop everything that was started.""" - self.hass.stop() + state = hass.states.get("sensor.dark_sky_summary") + assert state is not None - @patch( - "homeassistant.components.darksky.sensor.forecastio.load_forecast", - new=load_forecastMock, - ) - def test_setup_with_config(self): - """Test the platform setup with configuration.""" - setup_component(self.hass, "sensor", VALID_CONFIG_MINIMAL) - self.hass.block_till_done() - state = self.hass.states.get("sensor.dark_sky_summary") - assert state is not None +async def test_setup_with_invalid_language_config(hass): + """Test the platform setup with language configuration.""" + assert await async_setup_component(hass, "sensor", INVALID_CONFIG_LANG) + await hass.async_block_till_done() - def test_setup_with_invalid_config(self): - """Test the platform setup with invalid configuration.""" - setup_component(self.hass, "sensor", INVALID_CONFIG_MINIMAL) - self.hass.block_till_done() + state = hass.states.get("sensor.dark_sky_summary") + assert state is None - state = self.hass.states.get("sensor.dark_sky_summary") - assert state is None - @patch( - "homeassistant.components.darksky.sensor.forecastio.load_forecast", - new=load_forecastMock, +async def test_setup_bad_api_key(hass, requests_mock): + """Test for handling a bad API key.""" + # The Dark Sky API wrapper that we use raises an HTTP error + # when you try to use a bad (or no) API key. + url = "https://api.darksky.net/forecast/{}/{},{}?units=auto".format( + "foo", str(hass.config.latitude), str(hass.config.longitude) ) - def test_setup_with_language_config(self): - """Test the platform setup with language configuration.""" - setup_component(self.hass, "sensor", VALID_CONFIG_LANG_DE) - self.hass.block_till_done() + msg = f"400 Client Error: Bad Request for url: {url}" + requests_mock.get(url, text=msg, status_code=400) - state = self.hass.states.get("sensor.dark_sky_summary") - assert state is not None + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": "darksky", "api_key": "foo"}} + ) + await hass.async_block_till_done() - def test_setup_with_invalid_language_config(self): - """Test the platform setup with language configuration.""" - setup_component(self.hass, "sensor", INVALID_CONFIG_LANG) - self.hass.block_till_done() + assert hass.states.get("sensor.dark_sky_summary") is None - state = self.hass.states.get("sensor.dark_sky_summary") - assert state is None - @patch("forecastio.api.get_forecast") - def test_setup_bad_api_key(self, mock_get_forecast): - """Test for handling a bad API key.""" - # The Dark Sky API wrapper that we use raises an HTTP error - # when you try to use a bad (or no) API key. - url = "https://api.darksky.net/forecast/{}/{},{}?units=auto".format( - self.key, str(self.lat), str(self.lon) - ) - msg = f"400 Client Error: Bad Request for url: {url}" - mock_get_forecast.side_effect = HTTPError(msg) +async def test_connection_error(hass): + """Test setting up with a connection error.""" + with patch( + "homeassistant.components.darksky.sensor.forecastio.load_forecast", + side_effect=ConnectError(), + ): + await async_setup_component(hass, "sensor", VALID_CONFIG_MINIMAL) + await hass.async_block_till_done() - response = darksky.setup_platform( - self.hass, VALID_CONFIG_MINIMAL["sensor"], MagicMock() - ) - assert not response + state = hass.states.get("sensor.dark_sky_summary") + assert state is None - @patch( - "homeassistant.components.darksky.sensor.forecastio.load_forecast", - new=load_forecastMock, - ) - def test_setup_with_alerts_config(self): - """Test the platform setup with alert configuration.""" - setup_component(self.hass, "sensor", VALID_CONFIG_ALERTS) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.dark_sky_alerts") - assert state.state == "0" - - @requests_mock.Mocker() - @patch("forecastio.api.get_forecast", wraps=forecastio.api.get_forecast) - def test_setup(self, mock_req, mock_get_forecast): - """Test for successfully setting up the forecast.io platform.""" + +async def test_setup(hass, requests_mock): + """Test for successfully setting up the forecast.io platform.""" + with patch( + "forecastio.api.get_forecast", wraps=forecastio.api.get_forecast + ) as mock_get_forecast: uri = ( r"https://api.(darksky.net|forecast.io)\/forecast\/(\w+)\/" r"(-?\d+\.?\d*),(-?\d+\.?\d*)" ) - mock_req.get(re.compile(uri), text=load_fixture("darksky.json")) + requests_mock.get(re.compile(uri), text=load_fixture("darksky.json")) - assert setup_component(self.hass, "sensor", VALID_CONFIG_MINIMAL) - self.hass.block_till_done() + assert await async_setup_component(hass, "sensor", VALID_CONFIG_MINIMAL) + await hass.async_block_till_done() - assert mock_get_forecast.called assert mock_get_forecast.call_count == 1 - assert len(self.hass.states.entity_ids()) == 13 + assert len(hass.states.async_entity_ids()) == 13 - state = self.hass.states.get("sensor.dark_sky_summary") + state = hass.states.get("sensor.dark_sky_summary") assert state is not None assert state.state == "Clear" assert state.attributes.get("friendly_name") == "Dark Sky Summary" - state = self.hass.states.get("sensor.dark_sky_alerts") + state = hass.states.get("sensor.dark_sky_alerts") assert state.state == "2" - state = self.hass.states.get("sensor.dark_sky_daytime_high_temperature_1d") + state = hass.states.get("sensor.dark_sky_daytime_high_temperature_1d") assert state is not None assert state.attributes.get("device_class") == "temperature" diff --git a/tests/components/darksky/test_weather.py b/tests/components/darksky/test_weather.py index 9a1b3912b87cffdde69a3062f09f670d95e1ac1c..4a982577d481cf490b5efb923730d384c9d993e9 100644 --- a/tests/components/darksky/test_weather.py +++ b/tests/components/darksky/test_weather.py @@ -1,67 +1,50 @@ """The tests for the Dark Sky weather component.""" import re -import unittest from unittest.mock import patch import forecastio -from requests.exceptions import ConnectionError -import requests_mock +from requests.exceptions import ConnectionError as ConnectError from homeassistant.components import weather -from homeassistant.setup import setup_component -from homeassistant.util.unit_system import METRIC_SYSTEM - -from tests.common import get_test_home_assistant, load_fixture - - -class TestDarkSky(unittest.TestCase): - """Test the Dark Sky weather component.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.config.units = METRIC_SYSTEM - self.lat = self.hass.config.latitude = 37.8267 - self.lon = self.hass.config.longitude = -122.423 - self.addCleanup(self.tear_down_cleanup) - - def tear_down_cleanup(self): - """Stop down everything that was started.""" - self.hass.stop() - - @requests_mock.Mocker() - @patch("forecastio.api.get_forecast", wraps=forecastio.api.get_forecast) - def test_setup(self, mock_req, mock_get_forecast): - """Test for successfully setting up the forecast.io platform.""" - uri = ( - r"https://api.(darksky.net|forecast.io)\/forecast\/(\w+)\/" - r"(-?\d+\.?\d*),(-?\d+\.?\d*)" +from homeassistant.setup import async_setup_component + +from tests.common import load_fixture + + +async def test_setup(hass, requests_mock): + """Test for successfully setting up the forecast.io platform.""" + with patch( + "forecastio.api.get_forecast", wraps=forecastio.api.get_forecast + ) as mock_get_forecast: + requests_mock.get( + re.compile( + r"https://api.(darksky.net|forecast.io)\/forecast\/(\w+)\/" + r"(-?\d+\.?\d*),(-?\d+\.?\d*)" + ), + text=load_fixture("darksky.json"), ) - mock_req.get(re.compile(uri), text=load_fixture("darksky.json")) - assert setup_component( - self.hass, + assert await async_setup_component( + hass, weather.DOMAIN, {"weather": {"name": "test", "platform": "darksky", "api_key": "foo"}}, ) - self.hass.block_till_done() + await hass.async_block_till_done() - assert mock_get_forecast.called assert mock_get_forecast.call_count == 1 - - state = self.hass.states.get("weather.test") + state = hass.states.get("weather.test") assert state.state == "sunny" - @patch("forecastio.load_forecast", side_effect=ConnectionError()) - def test_failed_setup(self, mock_load_forecast): - """Test to ensure that a network error does not break component state.""" - assert setup_component( - self.hass, +async def test_failed_setup(hass): + """Test to ensure that a network error does not break component state.""" + with patch("forecastio.load_forecast", side_effect=ConnectError()): + assert await async_setup_component( + hass, weather.DOMAIN, {"weather": {"name": "test", "platform": "darksky", "api_key": "foo"}}, ) - self.hass.block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get("weather.test") + state = hass.states.get("weather.test") assert state.state == "unavailable" diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 2f21081a5ae15c1ca53fc792d0433a343ca9ed55..7a4c73923ec99547be97c0d719fda4060dc1e5d6 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -545,7 +545,9 @@ async def test_flow_hassio_discovery(hass): CONF_PORT: 80, CONF_SERIAL: BRIDGEID, CONF_API_KEY: API_KEY, - } + }, + name="Mock Addon", + slug="deconz", ), context={"source": SOURCE_HASSIO}, ) @@ -593,7 +595,9 @@ async def test_hassio_discovery_update_configuration(hass, aioclient_mock): CONF_PORT: 8080, CONF_API_KEY: "updated", CONF_SERIAL: BRIDGEID, - } + }, + name="Mock Addon", + slug="deconz", ), context={"source": SOURCE_HASSIO}, ) @@ -619,7 +623,9 @@ async def test_hassio_discovery_dont_update_configuration(hass, aioclient_mock): CONF_PORT: 80, CONF_API_KEY: API_KEY, CONF_SERIAL: BRIDGEID, - } + }, + name="Mock Addon", + slug="deconz", ), context={"source": SOURCE_HASSIO}, ) diff --git a/tests/components/deconz/test_number.py b/tests/components/deconz/test_number.py index e0c469a1ba2f9743987678a941c59a1c0686edf9..63dac8dde377a40c1de02cbf0162d92393e52ce7 100644 --- a/tests/components/deconz/test_number.py +++ b/tests/components/deconz/test_number.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest +from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -44,7 +45,8 @@ TEST_DATA = [ "entity_count": 3, "device_count": 3, "entity_id": "number.presence_sensor_delay", - "unique_id": "00:00:00:00:00:00:00:00-delay", + "unique_id": "00:00:00:00:00:00:00:00-00-delay", + "old_unique_id": "00:00:00:00:00:00:00:00-delay", "state": "0", "entity_category": EntityCategory.CONFIG, "attributes": { @@ -62,7 +64,43 @@ TEST_DATA = [ "unsupported_service_response": {"delay": 0}, "out_of_range_service_value": 66666, }, - ) + ), + ( # Presence sensor - duration configuration + { + "name": "Presence sensor", + "type": "ZHAPresence", + "state": {"dark": False, "presence": False}, + "config": { + "duration": 0, + "on": True, + "reachable": True, + "temperature": 10, + }, + "uniqueid": "00:00:00:00:00:00:00:00-00", + }, + { + "entity_count": 3, + "device_count": 3, + "entity_id": "number.presence_sensor_duration", + "unique_id": "00:00:00:00:00:00:00:00-00-duration", + "state": "0", + "entity_category": EntityCategory.CONFIG, + "attributes": { + "min": 0, + "max": 65535, + "step": 1, + "mode": "auto", + "friendly_name": "Presence sensor Duration", + }, + "websocket_event": {"config": {"duration": 10}}, + "next_state": "10", + "supported_service_value": 111, + "supported_service_response": {"duration": 111}, + "unsupported_service_value": 0.1, + "unsupported_service_response": {"duration": 0}, + "out_of_range_service_value": 66666, + }, + ), ] @@ -74,6 +112,15 @@ async def test_number_entities( ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) + # Create entity entry to migrate to new unique ID + if "old_unique_id" in expected: + ent_reg.async_get_or_create( + NUMBER_DOMAIN, + DECONZ_DOMAIN, + expected["old_unique_id"], + suggested_object_id=expected["entity_id"].replace("number.", ""), + ) + with patch.dict(DECONZ_WEB_REQUEST, {"sensors": {"0": sensor_data}}): config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -105,8 +152,7 @@ async def test_number_entities( "e": "changed", "r": "sensors", "id": "0", - "config": {"delay": 10}, - } + } | expected["websocket_event"] await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() assert hass.states.get(expected["entity_id"]).state == expected["next_state"] diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 1078d888c0ed804a078a9559f04f2b2a343ea216..ac8100caa3d8482d01d9471cdc7bd2a70f39c1c4 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -104,7 +104,6 @@ TEST_DATA = [ "state_class": SensorStateClass.MEASUREMENT, "attributes": { "state_class": "measurement", - "unit_of_measurement": "ppb", "device_class": "aqi", "friendly_name": "BOSCH Air quality sensor PPB", }, @@ -521,9 +520,7 @@ TEST_DATA = [ "state": "2020-11-19T08:07:08+00:00", "entity_category": None, "device_class": SensorDeviceClass.TIMESTAMP, - "state_class": SensorStateClass.TOTAL_INCREASING, "attributes": { - "state_class": "total_increasing", "device_class": "timestamp", "friendly_name": "eTRV Séjour", }, diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index f8f8c20dbb2417671b8d0a171b7d329ce85c55b0..186e019fcbb012a8f29f00f8d7ad099b9fe5a3be 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -12,7 +12,9 @@ from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: @pytest.fixture(autouse=True) def mock_ssdp(): """Mock ssdp.""" - with patch("homeassistant.components.ssdp.Scanner.async_scan"): + with patch("homeassistant.components.ssdp.Scanner.async_scan"), patch( + "homeassistant.components.ssdp.Server.async_start" + ), patch("homeassistant.components.ssdp.Server.async_stop"): yield diff --git a/tests/components/demo/test_init.py b/tests/components/demo/test_init.py index f7a89fe63c1e53fb3564dcf35a598731b16bfa3e..c1b9d4c436e8620620769571eb175064b14439c8 100644 --- a/tests/components/demo/test_init.py +++ b/tests/components/demo/test_init.py @@ -17,25 +17,25 @@ from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done -@pytest.fixture(autouse=True) +@pytest.fixture def mock_history(hass): """Mock history component loaded.""" hass.config.components.add("history") @pytest.fixture(autouse=True) -def mock_device_tracker_update_config(hass): +def mock_device_tracker_update_config(): """Prevent device tracker from creating known devices file.""" with patch("homeassistant.components.device_tracker.legacy.update_config"): yield -async def test_setting_up_demo(hass): +async def test_setting_up_demo(mock_history, hass): """Test if we can set up the demo and dump it to JSON.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -52,7 +52,7 @@ async def test_setting_up_demo(hass): ) -async def test_demo_statistics(hass, recorder_mock): +async def test_demo_statistics(recorder_mock, mock_history, hass): """Test that the demo components makes some statistics available.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -82,9 +82,9 @@ async def test_demo_statistics(hass, recorder_mock): } in statistic_ids -async def test_demo_statistics_growth(hass, recorder_mock): +async def test_demo_statistics_growth(recorder_mock, mock_history, hass): """Test that the demo sum statistics adds to the previous state.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM now = dt_util.now() last_week = now - datetime.timedelta(days=7) @@ -120,7 +120,7 @@ async def test_demo_statistics_growth(hass, recorder_mock): assert statistics[statistic_id][0]["sum"] <= (2**20 + 24) -async def test_issues_created(hass, hass_client, hass_ws_client): +async def test_issues_created(mock_history, hass, hass_client, hass_ws_client): """Test issues are created and can be fixed.""" assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/demo/test_water_heater.py b/tests/components/demo/test_water_heater.py index a6b4d999ddbf09219b7912b76493963b046e5c27..75103fca920a171c0f6942363e0b2631ebc9e602 100644 --- a/tests/components/demo/test_water_heater.py +++ b/tests/components/demo/test_water_heater.py @@ -4,7 +4,7 @@ import voluptuous as vol from homeassistant.components import water_heater from homeassistant.setup import async_setup_component -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.components.water_heater import common @@ -15,7 +15,7 @@ ENTITY_WATER_HEATER_CELSIUS = "water_heater.demo_water_heater_celsius" @pytest.fixture(autouse=True) async def setup_comp(hass): """Set up demo component.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM assert await async_setup_component( hass, water_heater.DOMAIN, {"water_heater": {"platform": "demo"}} ) diff --git a/tests/components/devolo_home_network/__init__.py b/tests/components/devolo_home_network/__init__.py index bb861081517f47728b28c8f49e4d8e93955e8e48..f42abef20ec2c8a720afebdcf48310ecd71c8b5e 100644 --- a/tests/components/devolo_home_network/__init__.py +++ b/tests/components/devolo_home_network/__init__.py @@ -1,16 +1,9 @@ """Tests for the devolo Home Network integration.""" - -import dataclasses -from typing import Any - -from devolo_plc_api.device_api.deviceapi import DeviceApi -from devolo_plc_api.plcnet_api.plcnetapi import PlcNetApi - from homeassistant.components.devolo_home_network.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import DISCOVERY_INFO, IP +from .const import IP from tests.common import MockConfigEntry @@ -19,17 +12,9 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: """Configure the integration.""" config = { CONF_IP_ADDRESS: IP, + CONF_PASSWORD: "", } entry = MockConfigEntry(domain=DOMAIN, data=config) entry.add_to_hass(hass) return entry - - -async def async_connect(self, session_instance: Any = None): - """Give a mocked device the needed properties.""" - self.plcnet = PlcNetApi(IP, None, dataclasses.asdict(DISCOVERY_INFO)) - self.device = DeviceApi(IP, None, dataclasses.asdict(DISCOVERY_INFO)) - self.mac = DISCOVERY_INFO.properties["PlcMacAddress"] - self.product = DISCOVERY_INFO.properties["Product"] - self.serial_number = DISCOVERY_INFO.properties["SN"] diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py index 1d8d2a6da195328fd74536a603f3df81644e115b..98a79faae54c3623974c73d6c9587a2af93bd6f0 100644 --- a/tests/components/devolo_home_network/conftest.py +++ b/tests/components/devolo_home_network/conftest.py @@ -1,29 +1,22 @@ """Fixtures for tests.""" - -from unittest.mock import AsyncMock, patch +from itertools import cycle +from unittest.mock import patch import pytest -from . import async_connect -from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NEIGHBOR_ACCESS_POINTS, PLCNET +from .const import DISCOVERY_INFO, IP +from .mock import MockDevice @pytest.fixture() def mock_device(): """Mock connecting to a devolo home network device.""" - with patch("devolo_plc_api.device.Device.async_connect", async_connect), patch( - "devolo_plc_api.device.Device.async_disconnect" - ), patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_connected_station", - new=AsyncMock(return_value=CONNECTED_STATIONS), - ), patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_neighbor_access_points", - new=AsyncMock(return_value=NEIGHBOR_ACCESS_POINTS), - ), patch( - "devolo_plc_api.plcnet_api.plcnetapi.PlcNetApi.async_get_network_overview", - new=AsyncMock(return_value=PLCNET), + device = MockDevice(ip=IP) + with patch( + "homeassistant.components.devolo_home_network.Device", + side_effect=cycle([device]), ): - yield + yield device @pytest.fixture(name="info") diff --git a/tests/components/devolo_home_network/mock.py b/tests/components/devolo_home_network/mock.py new file mode 100644 index 0000000000000000000000000000000000000000..660cc19f78c4dd0c6a48d57419bc342580e7062d --- /dev/null +++ b/tests/components/devolo_home_network/mock.py @@ -0,0 +1,57 @@ +"""Mock of a devolo Home Network device.""" +from __future__ import annotations + +import dataclasses +from typing import Any +from unittest.mock import AsyncMock + +from devolo_plc_api.device import Device +from devolo_plc_api.device_api.deviceapi import DeviceApi +from devolo_plc_api.plcnet_api.plcnetapi import PlcNetApi +import httpx +from zeroconf import Zeroconf +from zeroconf.asyncio import AsyncZeroconf + +from .const import ( + CONNECTED_STATIONS, + DISCOVERY_INFO, + IP, + NEIGHBOR_ACCESS_POINTS, + PLCNET, +) + + +class MockDevice(Device): + """Mock of a devolo Home Network device.""" + + def __init__( + self, + ip: str, + plcnetapi: dict[str, Any] | None = None, + deviceapi: dict[str, Any] | None = None, + zeroconf_instance: AsyncZeroconf | Zeroconf | None = None, + ) -> None: + """Bring mock in a well defined state.""" + super().__init__(ip, plcnetapi, deviceapi, zeroconf_instance) + self.reset() + + async def async_connect( + self, session_instance: httpx.AsyncClient | None = None + ) -> None: + """Give a mocked device the needed properties.""" + self.mac = DISCOVERY_INFO.properties["PlcMacAddress"] + self.product = DISCOVERY_INFO.properties["Product"] + self.serial_number = DISCOVERY_INFO.properties["SN"] + + def reset(self): + """Reset mock to starting point.""" + self.async_disconnect = AsyncMock() + self.device = DeviceApi(IP, None, dataclasses.asdict(DISCOVERY_INFO)) + self.device.async_get_wifi_connected_station = AsyncMock( + return_value=CONNECTED_STATIONS + ) + self.device.async_get_wifi_neighbor_access_points = AsyncMock( + return_value=NEIGHBOR_ACCESS_POINTS + ) + self.plcnet = PlcNetApi(IP, None, dataclasses.asdict(DISCOVERY_INFO)) + self.plcnet.async_get_network_overview = AsyncMock(return_value=PLCNET) diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index 8f9936be5bb9940f7df696d99b7a0b15cc2abf13..d18dbca1f5f3ebe6c3adcc85e7c806a550c6388b 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -1,5 +1,5 @@ """Tests for the devolo Home Network sensors.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable import pytest @@ -22,6 +22,7 @@ from homeassistant.util import dt from . import configure_integration from .const import PLCNET_ATTACHED +from .mock import MockDevice from tests.common import async_fire_time_changed @@ -39,8 +40,8 @@ async def test_binary_sensor_setup(hass: HomeAssistant): await hass.config_entries.async_unload(entry.entry_id) -@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_device") -async def test_update_attached_to_router(hass: HomeAssistant): +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_attached_to_router(hass: HomeAssistant, mock_device: MockDevice): """Test state change of a attached_to_router binary sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() @@ -59,27 +60,25 @@ async def test_update_attached_to_router(hass: HomeAssistant): assert er.async_get(state_key).entity_category == EntityCategory.DIAGNOSTIC # Emulate device failure - with patch( - "devolo_plc_api.plcnet_api.plcnetapi.PlcNetApi.async_get_network_overview", - side_effect=DeviceUnavailable, - ): - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() + mock_device.plcnet.async_get_network_overview = AsyncMock( + side_effect=DeviceUnavailable + ) + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE # Emulate state change - with patch( - "devolo_plc_api.plcnet_api.plcnetapi.PlcNetApi.async_get_network_overview", - new=AsyncMock(return_value=PLCNET_ATTACHED), - ): - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_ON + mock_device.plcnet.async_get_network_overview = AsyncMock( + return_value=PLCNET_ATTACHED + ) + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_ON await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py index f9d589eb638a7e9bc1c0152b838a6ba1b8ff72c0..0d35630407eb6f571ba00845fee8656a8af064cb 100644 --- a/tests/components/devolo_home_network/test_config_flow.py +++ b/tests/components/devolo_home_network/test_config_flow.py @@ -7,18 +7,20 @@ from unittest.mock import patch from devolo_plc_api.exceptions.device import DeviceNotFound import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.devolo_home_network import config_flow from homeassistant.components.devolo_home_network.const import ( DOMAIN, SERIAL_NUMBER, TITLE, ) -from homeassistant.const import CONF_BASE, CONF_IP_ADDRESS, CONF_NAME +from homeassistant.const import CONF_BASE, CONF_IP_ADDRESS, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import configure_integration from .const import DISCOVERY_INFO, DISCOVERY_INFO_WRONG_DEVICE, IP +from .mock import MockDevice async def test_form(hass: HomeAssistant, info: dict[str, Any]): @@ -46,6 +48,7 @@ async def test_form(hass: HomeAssistant, info: dict[str, Any]): assert result2["title"] == info["title"] assert result2["data"] == { CONF_IP_ADDRESS: IP, + CONF_PASSWORD: "", } assert len(mock_setup_entry.mock_calls) == 1 @@ -111,6 +114,7 @@ async def test_zeroconf(hass: HomeAssistant): assert result2["title"] == "test" assert result2["data"] == { CONF_IP_ADDRESS: IP, + CONF_PASSWORD: "", } @@ -167,10 +171,53 @@ async def test_abort_if_configued(hass: HomeAssistant): assert result3["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_form_reauth(hass: HomeAssistant): + """Test that the reauth confirmation form is served.""" + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "title_placeholders": { + CONF_NAME: DISCOVERY_INFO.hostname.split(".")[0], + }, + }, + data=entry.data, + ) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + with patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "test-password-new"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + await hass.config_entries.async_unload(entry.entry_id) + + @pytest.mark.usefixtures("mock_device") @pytest.mark.usefixtures("mock_zeroconf") async def test_validate_input(hass: HomeAssistant): """Test input validation.""" - info = await config_flow.validate_input(hass, {CONF_IP_ADDRESS: IP}) - assert SERIAL_NUMBER in info - assert TITLE in info + with patch( + "homeassistant.components.devolo_home_network.config_flow.Device", + new=MockDevice, + ): + info = await config_flow.validate_input(hass, {CONF_IP_ADDRESS: IP}) + assert SERIAL_NUMBER in info + assert TITLE in info diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 233a480b5e3ca251f37b26b5e4523c96b6173e80..2f8fea3e749e9619a2b82e7909615a081ae076b1 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -1,8 +1,7 @@ """Tests for the devolo Home Network device tracker.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable -import pytest from homeassistant.components.device_tracker import DOMAIN as PLATFORM from homeassistant.components.devolo_home_network.const import ( @@ -23,6 +22,7 @@ from homeassistant.util import dt from . import configure_integration from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NO_CONNECTED_STATIONS +from .mock import MockDevice from tests.common import async_fire_time_changed @@ -30,8 +30,7 @@ STATION = CONNECTED_STATIONS["connected_stations"][0] SERIAL = DISCOVERY_INFO.properties["SN"] -@pytest.mark.usefixtures("mock_device") -async def test_device_tracker(hass: HomeAssistant): +async def test_device_tracker(hass: HomeAssistant, mock_device: MockDevice): """Test device tracker states.""" state_key = f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION['mac_address'].lower().replace(':', '_')}" entry = configure_integration(hass) @@ -57,34 +56,31 @@ async def test_device_tracker(hass: HomeAssistant): ) # Emulate state change - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_connected_station", - new=AsyncMock(return_value=NO_CONNECTED_STATIONS), - ): - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() + mock_device.device.async_get_wifi_connected_station = AsyncMock( + return_value=NO_CONNECTED_STATIONS + ) + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_NOT_HOME + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_NOT_HOME # Emulate device failure - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_connected_station", - side_effect=DeviceUnavailable, - ): - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() + mock_device.device.async_get_wifi_connected_station = AsyncMock( + side_effect=DeviceUnavailable + ) + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE await hass.config_entries.async_unload(entry.entry_id) -@pytest.mark.usefixtures("mock_device") -async def test_restoring_clients(hass: HomeAssistant): +async def test_restoring_clients(hass: HomeAssistant, mock_device: MockDevice): """Test restoring existing device_tracker entities.""" state_key = f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION['mac_address'].lower().replace(':', '_')}" entry = configure_integration(hass) @@ -96,12 +92,13 @@ async def test_restoring_clients(hass: HomeAssistant): config_entry=entry, ) - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_connected_station", - new=AsyncMock(return_value=NO_CONNECTED_STATIONS), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_NOT_HOME + mock_device.device.async_get_wifi_connected_station = AsyncMock( + return_value=NO_CONNECTED_STATIONS + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_NOT_HOME diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 1d15f337c1737f8c0fb9ac7e5ee5daf904d4678c..524590d7ead1828efc2531e75de17e765e314a4a 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -4,11 +4,16 @@ from unittest.mock import patch from devolo_plc_api.exceptions.device import DeviceNotFound import pytest +from homeassistant.components.devolo_home_network.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from . import configure_integration +from .const import IP +from .mock import MockDevice + +from tests.common import MockConfigEntry @pytest.mark.usefixtures("mock_device") @@ -23,6 +28,22 @@ async def test_setup_entry(hass: HomeAssistant): assert entry.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("mock_device") +async def test_setup_without_password(hass: HomeAssistant): + """Test setup entry without a device password set like used before HA Core 2022.06.""" + config = { + CONF_IP_ADDRESS: IP, + } + entry = MockConfigEntry(domain=DOMAIN, data=config) + entry.add_to_hass(hass) + with patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", + return_value=True, + ), patch("homeassistant.core.EventBus.async_listen_once"): + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + async def test_setup_device_not_found(hass: HomeAssistant): """Test setup entry.""" entry = configure_integration(hass) @@ -44,15 +65,11 @@ async def test_unload_entry(hass: HomeAssistant): assert entry.state is ConfigEntryState.NOT_LOADED -@pytest.mark.usefixtures("mock_device") -async def test_hass_stop(hass: HomeAssistant): +async def test_hass_stop(hass: HomeAssistant, mock_device: MockDevice): """Test homeassistant stop event.""" entry = configure_integration(hass) - with patch( - "homeassistant.components.devolo_home_network.Device.async_disconnect" - ) as async_disconnect: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - async_disconnect.assert_called_once() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_device.async_disconnect.assert_called_once() diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index 33499f512faaf246156ecf22a73463e0e2bb68ab..3002bd7c5b88d1b51146334ddeab726b1044ae35 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -1,5 +1,5 @@ """Tests for the devolo Home Network sensors.""" -from unittest.mock import patch +from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable import pytest @@ -16,6 +16,7 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.util import dt from . import configure_integration +from .mock import MockDevice from tests.common import async_fire_time_changed @@ -35,8 +36,9 @@ async def test_sensor_setup(hass: HomeAssistant): await hass.config_entries.async_unload(entry.entry_id) -@pytest.mark.usefixtures("mock_device") -async def test_update_connected_wifi_clients(hass: HomeAssistant): +async def test_update_connected_wifi_clients( + hass: HomeAssistant, mock_device: MockDevice +): """Test state change of a connected_wifi_clients sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() @@ -53,18 +55,18 @@ async def test_update_connected_wifi_clients(hass: HomeAssistant): assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT # Emulate device failure - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_connected_station", - side_effect=DeviceUnavailable, - ): - async_fire_time_changed(hass, dt.utcnow() + SHORT_UPDATE_INTERVAL) - await hass.async_block_till_done() + mock_device.device.async_get_wifi_connected_station = AsyncMock( + side_effect=DeviceUnavailable + ) + async_fire_time_changed(hass, dt.utcnow() + SHORT_UPDATE_INTERVAL) + await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE # Emulate state change + mock_device.reset() async_fire_time_changed(hass, dt.utcnow() + SHORT_UPDATE_INTERVAL) await hass.async_block_till_done() @@ -75,8 +77,10 @@ async def test_update_connected_wifi_clients(hass: HomeAssistant): await hass.config_entries.async_unload(entry.entry_id) -@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_device") -async def test_update_neighboring_wifi_networks(hass: HomeAssistant): +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_neighboring_wifi_networks( + hass: HomeAssistant, mock_device: MockDevice +): """Test state change of a neighboring_wifi_networks sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() @@ -95,18 +99,18 @@ async def test_update_neighboring_wifi_networks(hass: HomeAssistant): assert er.async_get(state_key).entity_category is EntityCategory.DIAGNOSTIC # Emulate device failure - with patch( - "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_neighbor_access_points", - side_effect=DeviceUnavailable, - ): - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() + mock_device.device.async_get_wifi_neighbor_access_points = AsyncMock( + side_effect=DeviceUnavailable + ) + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE # Emulate state change + mock_device.reset() async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() @@ -117,8 +121,10 @@ async def test_update_neighboring_wifi_networks(hass: HomeAssistant): await hass.config_entries.async_unload(entry.entry_id) -@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_device") -async def test_update_connected_plc_devices(hass: HomeAssistant): +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_connected_plc_devices( + hass: HomeAssistant, mock_device: MockDevice +): """Test state change of a connected_plc_devices sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() @@ -136,18 +142,18 @@ async def test_update_connected_plc_devices(hass: HomeAssistant): assert er.async_get(state_key).entity_category is EntityCategory.DIAGNOSTIC # Emulate device failure - with patch( - "devolo_plc_api.plcnet_api.plcnetapi.PlcNetApi.async_get_network_overview", - side_effect=DeviceUnavailable, - ): - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() + mock_device.plcnet.async_get_network_overview = AsyncMock( + side_effect=DeviceUnavailable + ) + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE # Emulate state change + mock_device.reset() async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() diff --git a/tests/components/dlna_dmr/conftest.py b/tests/components/dlna_dmr/conftest.py index 84aec044caf9cff6f7cf68e19af73895fda8f877..521f770a8fab2799608bb366552f3eb13f0c90db 100644 --- a/tests/components/dlna_dmr/conftest.py +++ b/tests/components/dlna_dmr/conftest.py @@ -116,13 +116,20 @@ def dmr_device_mock(domain_data_mock: Mock) -> Iterable[Mock]: @pytest.fixture(autouse=True) def ssdp_scanner_mock() -> Iterable[Mock]: - """Mock the SSDP module.""" + """Mock the SSDP Scanner.""" with patch("homeassistant.components.ssdp.Scanner", autospec=True) as mock_scanner: reg_callback = mock_scanner.return_value.async_register_callback reg_callback.return_value = Mock(return_value=None) yield mock_scanner.return_value +@pytest.fixture(autouse=True) +def ssdp_server_mock() -> Iterable[Mock]: + """Mock the SSDP Server.""" + with patch("homeassistant.components.ssdp.Server", autospec=True): + yield + + @pytest.fixture(autouse=True) def async_get_local_ip_mock() -> Iterable[Mock]: """Mock the async_get_local_ip utility function to prevent network access.""" diff --git a/tests/components/dlna_dms/conftest.py b/tests/components/dlna_dms/conftest.py index 4dcd135ea8663ede934fd5a9b6a629717ab5ec56..5b785fb4ba502868236e54830b695afea578d979 100644 --- a/tests/components/dlna_dms/conftest.py +++ b/tests/components/dlna_dms/conftest.py @@ -129,13 +129,20 @@ def dms_device_mock(upnp_factory_mock: Mock) -> Iterable[Mock]: @pytest.fixture(autouse=True) def ssdp_scanner_mock() -> Iterable[Mock]: - """Mock the SSDP module.""" + """Mock the SSDP Scanner.""" with patch("homeassistant.components.ssdp.Scanner", autospec=True) as mock_scanner: reg_callback = mock_scanner.return_value.async_register_callback reg_callback.return_value = Mock(return_value=None) yield mock_scanner.return_value +@pytest.fixture(autouse=True) +def ssdp_server_mock() -> Iterable[Mock]: + """Mock the SSDP Server.""" + with patch("homeassistant.components.ssdp.Server", autospec=True): + yield + + @pytest.fixture async def device_source_mock( hass: HomeAssistant, diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 364fdeb365b02ed0c9cc0fa0f10092e87a6094c0..0108dd1de7693c8469767daee4b2df503689b29e 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -16,16 +16,16 @@ from homeassistant.components.sensor.recorder import compile_statistics from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, - ENERGY_KILO_WATT_HOUR, - ENERGY_MEGA_WATT_HOUR, - ENERGY_WATT_HOUR, STATE_UNKNOWN, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, + VOLUME_GALLONS, + UnitOfEnergy, ) from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done @@ -49,7 +49,7 @@ def get_statistics_for_entity(statistics_results, entity_id): return None -async def test_cost_sensor_no_states(hass, hass_storage, setup_integration) -> None: +async def test_cost_sensor_no_states(setup_integration, hass, hass_storage) -> None: """Test sensors are created.""" energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( @@ -75,7 +75,7 @@ async def test_cost_sensor_no_states(hass, hass_storage, setup_integration) -> N # TODO: No states, should the cost entity refuse to setup? -async def test_cost_sensor_attributes(hass, hass_storage, setup_integration) -> None: +async def test_cost_sensor_attributes(setup_integration, hass, hass_storage) -> None: """Test sensor attributes.""" energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( @@ -124,10 +124,10 @@ async def test_cost_sensor_attributes(hass, hass_storage, setup_integration) -> ], ) async def test_cost_sensor_price_entity_total_increasing( + setup_integration, hass, hass_storage, hass_ws_client, - setup_integration, initial_energy, initial_cost, price_entity, @@ -142,7 +142,7 @@ async def test_cost_sensor_price_entity_total_increasing( return compile_statistics(hass, now, now + timedelta(seconds=1)).platform_stats energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, } @@ -327,10 +327,10 @@ async def test_cost_sensor_price_entity_total_increasing( ) @pytest.mark.parametrize("energy_state_class", ["total", "measurement"]) async def test_cost_sensor_price_entity_total( + setup_integration, hass, hass_storage, hass_ws_client, - setup_integration, initial_energy, initial_cost, price_entity, @@ -346,7 +346,7 @@ async def test_cost_sensor_price_entity_total( return compile_statistics(hass, now, now + timedelta(seconds=1)).platform_stats energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: energy_state_class, } @@ -533,10 +533,10 @@ async def test_cost_sensor_price_entity_total( ) @pytest.mark.parametrize("energy_state_class", ["total"]) async def test_cost_sensor_price_entity_total_no_reset( + setup_integration, hass, hass_storage, hass_ws_client, - setup_integration, initial_energy, initial_cost, price_entity, @@ -552,7 +552,7 @@ async def test_cost_sensor_price_entity_total_no_reset( return compile_statistics(hass, now, now + timedelta(seconds=1)).platform_stats energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: energy_state_class, } @@ -700,13 +700,14 @@ async def test_cost_sensor_price_entity_total_no_reset( @pytest.mark.parametrize( "energy_unit,factor", [ - (ENERGY_WATT_HOUR, 1000), - (ENERGY_KILO_WATT_HOUR, 1), - (ENERGY_MEGA_WATT_HOUR, 0.001), + (UnitOfEnergy.WATT_HOUR, 1000), + (UnitOfEnergy.KILO_WATT_HOUR, 1), + (UnitOfEnergy.MEGA_WATT_HOUR, 0.001), + (UnitOfEnergy.GIGA_JOULE, 0.001 * 3.6), ], ) async def test_cost_sensor_handle_energy_units( - hass, hass_storage, setup_integration, energy_unit, factor + setup_integration, hass, hass_storage, energy_unit, factor ) -> None: """Test energy cost price from sensor entity.""" energy_attributes = { @@ -765,17 +766,18 @@ async def test_cost_sensor_handle_energy_units( @pytest.mark.parametrize( "price_unit,factor", [ - (f"EUR/{ENERGY_WATT_HOUR}", 0.001), - (f"EUR/{ENERGY_KILO_WATT_HOUR}", 1), - (f"EUR/{ENERGY_MEGA_WATT_HOUR}", 1000), + (f"EUR/{UnitOfEnergy.WATT_HOUR}", 0.001), + (f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", 1), + (f"EUR/{UnitOfEnergy.MEGA_WATT_HOUR}", 1000), + (f"EUR/{UnitOfEnergy.GIGA_JOULE}", 1000 / 3.6), ], ) async def test_cost_sensor_handle_price_units( - hass, hass_storage, setup_integration, price_unit, factor + setup_integration, hass, hass_storage, price_unit, factor ) -> None: """Test energy cost price from sensor entity.""" energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, } price_attributes = { @@ -832,9 +834,12 @@ async def test_cost_sensor_handle_price_units( assert state.state == "20.0" -@pytest.mark.parametrize("unit", (VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS)) +@pytest.mark.parametrize( + "unit", + (VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS), +) async def test_cost_sensor_handle_gas( - hass, hass_storage, setup_integration, unit + setup_integration, hass, hass_storage, unit ) -> None: """Test gas cost price from sensor entity.""" energy_attributes = { @@ -884,11 +889,11 @@ async def test_cost_sensor_handle_gas( async def test_cost_sensor_handle_gas_kwh( - hass, hass_storage, setup_integration + setup_integration, hass, hass_storage ) -> None: """Test gas cost price from sensor entity.""" energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, } energy_data = data.EnergyManager.default_preferences() @@ -933,13 +938,73 @@ async def test_cost_sensor_handle_gas_kwh( assert state.state == "50.0" +@pytest.mark.parametrize( + "unit_system,usage_unit,growth", + ( + # 1 cubic foot = 7.47 gl, 100 ft3 growth @ 0.5/ft3: + (US_CUSTOMARY_SYSTEM, VOLUME_CUBIC_FEET, 374.025974025974), + (US_CUSTOMARY_SYSTEM, VOLUME_GALLONS, 50.0), + (METRIC_SYSTEM, VOLUME_CUBIC_METERS, 50.0), + ), +) +async def test_cost_sensor_handle_water( + setup_integration, hass, hass_storage, unit_system, usage_unit, growth +) -> None: + """Test water cost price from sensor entity.""" + hass.config.units = unit_system + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: usage_unit, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "water", + "stat_energy_from": "sensor.water_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.5, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + + hass.states.async_set( + "sensor.water_consumption", + 100, + energy_attributes, + ) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get("sensor.water_consumption_cost") + assert state.state == "0.0" + + # water use bumped to 200 ft³/m³ + hass.states.async_set( + "sensor.water_consumption", + 200, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.water_consumption_cost") + assert float(state.state) == pytest.approx(growth) + + @pytest.mark.parametrize("state_class", [None]) async def test_cost_sensor_wrong_state_class( - hass, hass_storage, setup_integration, caplog, state_class + setup_integration, hass, hass_storage, caplog, state_class ) -> None: """Test energy sensor rejects sensor with wrong state_class.""" energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: state_class, } energy_data = data.EnergyManager.default_preferences() @@ -996,11 +1061,11 @@ async def test_cost_sensor_wrong_state_class( @pytest.mark.parametrize("state_class", [SensorStateClass.MEASUREMENT]) async def test_cost_sensor_state_class_measurement_no_reset( - hass, hass_storage, setup_integration, caplog, state_class + setup_integration, hass, hass_storage, caplog, state_class ) -> None: """Test energy sensor rejects state_class measurement with no last_reset.""" energy_attributes = { - ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_STATE_CLASS: state_class, } energy_data = data.EnergyManager.default_preferences() @@ -1051,7 +1116,7 @@ async def test_cost_sensor_state_class_measurement_no_reset( assert state.state == STATE_UNKNOWN -async def test_inherit_source_unique_id(hass, hass_storage, setup_integration): +async def test_inherit_source_unique_id(setup_integration, hass, hass_storage): """Test sensor inherits unique ID from source.""" energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 9e78e91f7f7a6b4771cbe0cb2cda49e758496188..f1e626c24d5c6ff060eafb8d5d1af9d537c5d1a8 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -4,11 +4,7 @@ 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.const import UnitOfEnergy from homeassistant.helpers.json import JSON_DUMP from homeassistant.setup import async_setup_component @@ -48,7 +44,7 @@ def mock_get_metadata(): @pytest.fixture(autouse=True) -async def mock_energy_manager(hass, recorder_mock): +async def mock_energy_manager(recorder_mock, hass): """Set up energy.""" assert await async_setup_component(hass, "energy", {"energy": {}}) manager = await async_get_manager(hass) @@ -67,12 +63,13 @@ async def test_validation_empty_config(hass): @pytest.mark.parametrize( "state_class, energy_unit, extra", [ - ("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"}), + ("total_increasing", UnitOfEnergy.KILO_WATT_HOUR, {}), + ("total_increasing", UnitOfEnergy.MEGA_WATT_HOUR, {}), + ("total_increasing", UnitOfEnergy.WATT_HOUR, {}), + ("total", UnitOfEnergy.KILO_WATT_HOUR, {}), + ("total", UnitOfEnergy.KILO_WATT_HOUR, {"last_reset": "abc"}), + ("measurement", UnitOfEnergy.KILO_WATT_HOUR, {"last_reset": "abc"}), + ("total_increasing", UnitOfEnergy.GIGA_JOULE, {}), ], ) async def test_validation( @@ -949,3 +946,162 @@ async def test_validation_grid_no_costs_tracking( "energy_sources": [[]], "device_consumption": [], } + + +async def test_validation_water( + hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata +): + """Test validating water with sensors for energy and cost/compensation.""" + mock_is_entity_recorded["sensor.water_cost_1"] = False + mock_is_entity_recorded["sensor.water_compensation_1"] = False + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "water", + "stat_energy_from": "sensor.water_consumption_1", + "stat_cost": "sensor.water_cost_1", + }, + { + "type": "water", + "stat_energy_from": "sensor.water_consumption_2", + "stat_cost": "sensor.water_cost_2", + }, + { + "type": "water", + "stat_energy_from": "sensor.water_consumption_3", + "stat_cost": "sensor.water_cost_2", + }, + { + "type": "water", + "stat_energy_from": "sensor.water_consumption_4", + "entity_energy_price": "sensor.water_price_1", + }, + { + "type": "water", + "stat_energy_from": "sensor.water_consumption_3", + "entity_energy_price": "sensor.water_price_2", + }, + ] + } + ) + await hass.async_block_till_done() + hass.states.async_set( + "sensor.water_consumption_1", + "10.10", + { + "device_class": "water", + "unit_of_measurement": "beers", + "state_class": "total_increasing", + }, + ) + hass.states.async_set( + "sensor.water_consumption_2", + "10.10", + { + "device_class": "water", + "unit_of_measurement": "ft³", + "state_class": "total_increasing", + }, + ) + hass.states.async_set( + "sensor.water_consumption_3", + "10.10", + { + "device_class": "water", + "unit_of_measurement": "m³", + "state_class": "total_increasing", + }, + ) + hass.states.async_set( + "sensor.water_consumption_4", + "10.10", + {"unit_of_measurement": "beers", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.water_cost_2", + "10.10", + {"unit_of_measurement": "EUR/kWh", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.water_price_1", + "10.10", + {"unit_of_measurement": "EUR/m³", "state_class": "total_increasing"}, + ) + hass.states.async_set( + "sensor.water_price_2", + "10.10", + {"unit_of_measurement": "EUR/invalid", "state_class": "total_increasing"}, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [ + [ + { + "type": "entity_unexpected_unit_water", + "identifier": "sensor.water_consumption_1", + "value": "beers", + }, + { + "type": "recorder_untracked", + "identifier": "sensor.water_cost_1", + "value": None, + }, + { + "type": "entity_not_defined", + "identifier": "sensor.water_cost_1", + "value": None, + }, + ], + [], + [], + [ + { + "type": "entity_unexpected_device_class", + "identifier": "sensor.water_consumption_4", + "value": None, + }, + ], + [ + { + "type": "entity_unexpected_unit_water_price", + "identifier": "sensor.water_price_2", + "value": "EUR/invalid", + }, + ], + ], + "device_consumption": [], + } + + +async def test_validation_water_no_costs_tracking( + hass, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata +): + """Test validating water with sensors without cost tracking.""" + await mock_energy_manager.async_update( + { + "energy_sources": [ + { + "type": "water", + "stat_energy_from": "sensor.water_consumption_1", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": None, + }, + ] + } + ) + hass.states.async_set( + "sensor.water_consumption_1", + "10.10", + { + "device_class": "water", + "unit_of_measurement": "m³", + "state_class": "total_increasing", + }, + ) + + assert (await validate.async_validate(hass)).as_dict() == { + "energy_sources": [[]], + "device_consumption": [], + } diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index 343e814f3a89d204484f56c398ff7acd011ff06e..536077d6b159a42aea379443ee4fc8e8ad012bc4 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -16,7 +16,7 @@ from tests.components.recorder.common import ( @pytest.fixture(autouse=True) -async def setup_integration(hass, recorder_mock): +async def setup_integration(recorder_mock, hass): """Set up the integration.""" assert await async_setup_component(hass, "energy", {}) @@ -289,7 +289,7 @@ async def test_get_solar_forecast(hass, hass_ws_client, mock_energy_platform) -> @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") -async def test_fossil_energy_consumption_no_co2(hass, hass_ws_client, recorder_mock): +async def test_fossil_energy_consumption_no_co2(recorder_mock, hass, hass_ws_client): """Test fossil_energy_consumption when co2 data is missing.""" now = dt_util.utcnow() later = dt_util.as_utc(dt_util.parse_datetime("2022-09-01 00:00:00")) @@ -450,7 +450,7 @@ async def test_fossil_energy_consumption_no_co2(hass, hass_ws_client, recorder_m @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") -async def test_fossil_energy_consumption_hole(hass, hass_ws_client, recorder_mock): +async def test_fossil_energy_consumption_hole(recorder_mock, hass, hass_ws_client): """Test fossil_energy_consumption when some data points lack sum.""" now = dt_util.utcnow() later = dt_util.as_utc(dt_util.parse_datetime("2022-09-01 00:00:00")) @@ -611,7 +611,7 @@ async def test_fossil_energy_consumption_hole(hass, hass_ws_client, recorder_moc @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") -async def test_fossil_energy_consumption_no_data(hass, hass_ws_client, recorder_mock): +async def test_fossil_energy_consumption_no_data(recorder_mock, hass, hass_ws_client): """Test fossil_energy_consumption when there is no data.""" now = dt_util.utcnow() later = dt_util.as_utc(dt_util.parse_datetime("2022-09-01 00:00:00")) @@ -759,7 +759,7 @@ async def test_fossil_energy_consumption_no_data(hass, hass_ws_client, recorder_ @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") -async def test_fossil_energy_consumption(hass, hass_ws_client, recorder_mock): +async def test_fossil_energy_consumption(recorder_mock, hass, hass_ws_client): """Test fossil_energy_consumption with co2 sensor data.""" now = dt_util.utcnow() later = dt_util.as_utc(dt_util.parse_datetime("2022-09-01 00:00:00")) diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..93a76bdd510b706e80a24612fbc5ba629a92056a --- /dev/null +++ b/tests/components/enphase_envoy/conftest.py @@ -0,0 +1,110 @@ +"""Define test fixtures for Enphase Envoy.""" +import json +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.enphase_envoy import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass: HomeAssistant, config, serial_number): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=f"Envoy {serial_number}" if serial_number else "Envoy", + unique_id=serial_number, + data=config, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(): + """Define a config entry data fixture.""" + return { + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + + +@pytest.fixture(name="gateway_data", scope="package") +def gateway_data_fixture(): + """Define a fixture to return gateway data.""" + return json.loads(load_fixture("data.json", "enphase_envoy")) + + +@pytest.fixture(name="inverters_production_data", scope="package") +def inverters_production_data_fixture(): + """Define a fixture to return inverter production data.""" + return json.loads(load_fixture("inverters_production.json", "enphase_envoy")) + + +@pytest.fixture(name="mock_envoy_reader") +def mock_envoy_reader_fixture( + gateway_data, + mock_get_data, + mock_get_full_serial_number, + mock_inverters_production, + serial_number, +): + """Define a mocked EnvoyReader fixture.""" + mock_envoy_reader = Mock( + getData=mock_get_data, + get_full_serial_number=mock_get_full_serial_number, + inverters_production=mock_inverters_production, + ) + + for key, value in gateway_data.items(): + setattr(mock_envoy_reader, key, AsyncMock(return_value=value)) + + return mock_envoy_reader + + +@pytest.fixture(name="mock_get_full_serial_number") +def mock_get_full_serial_number_fixture(serial_number): + """Define a mocked EnvoyReader.get_full_serial_number fixture.""" + return AsyncMock(return_value=serial_number) + + +@pytest.fixture(name="mock_get_data") +def mock_get_data_fixture(): + """Define a mocked EnvoyReader.getData fixture.""" + return AsyncMock() + + +@pytest.fixture(name="mock_inverters_production") +def mock_inverters_production_fixture(inverters_production_data): + """Define a mocked EnvoyReader.inverters_production fixture.""" + return AsyncMock(return_value=inverters_production_data) + + +@pytest.fixture(name="setup_enphase_envoy") +async def setup_enphase_envoy_fixture(hass, config, mock_envoy_reader): + """Define a fixture to set up Enphase Envoy.""" + with patch( + "homeassistant.components.enphase_envoy.config_flow.EnvoyReader", + return_value=mock_envoy_reader, + ), patch( + "homeassistant.components.enphase_envoy.EnvoyReader", + return_value=mock_envoy_reader, + ), patch( + "homeassistant.components.enphase_envoy.PLATFORMS", [] + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="serial_number") +def serial_number_fixture(): + """Define a serial number fixture.""" + return "1234" diff --git a/tests/components/enphase_envoy/fixtures/__init__.py b/tests/components/enphase_envoy/fixtures/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b3ef7db17a340d78d1b53e8cf9983b44478b6776 --- /dev/null +++ b/tests/components/enphase_envoy/fixtures/__init__.py @@ -0,0 +1 @@ +"""Define data fixtures for Enphase Envoy.""" diff --git a/tests/components/enphase_envoy/fixtures/data.json b/tests/components/enphase_envoy/fixtures/data.json new file mode 100644 index 0000000000000000000000000000000000000000..d6868a6dbf7df548406aad7d6ca099c6c06ddd41 --- /dev/null +++ b/tests/components/enphase_envoy/fixtures/data.json @@ -0,0 +1,10 @@ +{ + "production": 1840, + "daily_production": 28223, + "seven_days_production": 174482, + "lifetime_production": 5924391, + "consumption": 1840, + "daily_consumption": 5923857, + "seven_days_consumption": 5923857, + "lifetime_consumption": 5923857 +} diff --git a/tests/components/enphase_envoy/fixtures/inverters_production.json b/tests/components/enphase_envoy/fixtures/inverters_production.json new file mode 100644 index 0000000000000000000000000000000000000000..14891f2d27857b61e5d7e4fd73db21c70b43c8c9 --- /dev/null +++ b/tests/components/enphase_envoy/fixtures/inverters_production.json @@ -0,0 +1,18 @@ +{ + "202140024014": [136, "2022-10-08 16:43:36"], + "202140023294": [163, "2022-10-08 16:43:41"], + "202140013819": [130, "2022-10-08 16:43:31"], + "202140023794": [139, "2022-10-08 16:43:38"], + "202140023381": [130, "2022-10-08 16:43:47"], + "202140024176": [54, "2022-10-08 16:43:59"], + "202140003284": [132, "2022-10-08 16:43:55"], + "202140019854": [129, "2022-10-08 16:43:58"], + "202140020743": [131, "2022-10-08 16:43:49"], + "202140023531": [28, "2022-10-08 16:43:53"], + "202140024241": [164, "2022-10-08 16:43:33"], + "202140022963": [164, "2022-10-08 16:43:41"], + "202140023149": [118, "2022-10-08 16:43:47"], + "202140024828": [129, "2022-10-08 16:43:36"], + "202140023269": [133, "2022-10-08 16:43:43"], + "202140024157": [112, "2022-10-08 16:43:52"] +} diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index caba229692774e043e835584a62f8e59a1e0070e..fac5b01c60e3763f046c3afbb80526756b179f84 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -1,46 +1,31 @@ """Test the Enphase Envoy config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import httpx +import pytest from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.components.enphase_envoy.const import DOMAIN -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry - -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, config, setup_enphase_envoy) -> None: """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.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ), patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.get_full_serial_number", - return_value="1234", - ), patch( - "homeassistant.components.enphase_envoy.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "create_entry" assert result2["title"] == "Envoy 1234" assert result2["data"] == { @@ -49,38 +34,27 @@ async def test_form(hass: HomeAssistant) -> None: "username": "test-username", "password": "test-password", } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_no_serial_number(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("serial_number", [None]) +async def test_user_no_serial_number( + hass: HomeAssistant, config, setup_enphase_envoy +) -> None: """Test user setup without a serial number.""" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ), patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.get_full_serial_number", - return_value=None, - ), patch( - "homeassistant.components.enphase_envoy.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "create_entry" assert result2["title"] == "Envoy" assert result2["data"] == { @@ -89,40 +63,36 @@ async def test_user_no_serial_number(hass: HomeAssistant) -> None: "username": "test-username", "password": "test-password", } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_fetching_serial_fails(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "mock_get_full_serial_number", + [ + AsyncMock( + side_effect=httpx.HTTPStatusError( + "any", request=MagicMock(), response=MagicMock() + ) + ) + ], +) +async def test_user_fetching_serial_fails( + hass: HomeAssistant, setup_enphase_envoy +) -> None: """Test user setup without a serial number.""" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ), patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.get_full_serial_number", - side_effect=httpx.HTTPStatusError( - "any", request=MagicMock(), response=MagicMock() - ), - ), patch( - "homeassistant.components.enphase_envoy.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "create_entry" assert result2["title"] == "Envoy" assert result2["data"] == { @@ -131,83 +101,75 @@ async def test_user_fetching_serial_fails(hass: HomeAssistant) -> None: "username": "test-username", "password": "test-password", } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "mock_get_data", + [ + AsyncMock( + side_effect=httpx.HTTPStatusError( + "any", request=MagicMock(), response=MagicMock() + ) + ) + ], +) +async def test_form_invalid_auth(hass: HomeAssistant, setup_enphase_envoy) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - side_effect=httpx.HTTPStatusError( - "any", request=MagicMock(), response=MagicMock() - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "form" assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "mock_get_data", [AsyncMock(side_effect=httpx.HTTPError("any"))] +) +async def test_form_cannot_connect(hass: HomeAssistant, setup_enphase_envoy) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - side_effect=httpx.HTTPError("any"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "form" assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unknown_error(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("mock_get_data", [AsyncMock(side_effect=ValueError)]) +async def test_form_unknown_error(hass: HomeAssistant, setup_enphase_envoy) -> None: """Test we handle unknown error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - side_effect=ValueError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "form" assert result2["errors"] == {"base": "unknown"} -async def test_zeroconf(hass: HomeAssistant) -> None: +async def test_zeroconf(hass: HomeAssistant, setup_enphase_envoy) -> None: """Test we can setup from zeroconf.""" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -221,28 +183,17 @@ async def test_zeroconf(hass: HomeAssistant) -> None: type="mock_type", ), ) - await hass.async_block_till_done() - assert result["type"] == "form" assert result["step_id"] == "user" - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ), patch( - "homeassistant.components.enphase_envoy.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "create_entry" assert result2["title"] == "Envoy 1234" assert result2["result"].unique_id == "1234" @@ -252,63 +203,34 @@ async def test_zeroconf(hass: HomeAssistant) -> None: "username": "test-username", "password": "test-password", } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_host_already_exists(hass: HomeAssistant) -> None: +async def test_form_host_already_exists( + hass: HomeAssistant, config_entry, setup_enphase_envoy +) -> None: """Test host already exists.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "host": "1.1.1.1", - "name": "Envoy", - "username": "test-username", - "password": "test-password", - }, - title="Envoy", - ) - config_entry.add_to_hass(hass) 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.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == "abort" - assert result2["reason"] == "already_configured" - - -async def test_zeroconf_serial_already_exists(hass: HomeAssistant) -> None: - """Test serial number already exists from zeroconf.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "host": "1.1.1.1", - "name": "Envoy", "username": "test-username", "password": "test-password", }, - unique_id="1234", - title="Envoy", ) - config_entry.add_to_hass(hass) + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + +async def test_zeroconf_serial_already_exists( + hass: HomeAssistant, config_entry, setup_enphase_envoy +) -> None: + """Test serial number already exists from zeroconf.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -322,28 +244,16 @@ async def test_zeroconf_serial_already_exists(hass: HomeAssistant) -> None: type="mock_type", ), ) - assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert config_entry.data[CONF_HOST] == "4.4.4.4" + assert config_entry.data["host"] == "4.4.4.4" -async def test_zeroconf_serial_already_exists_ignores_ipv6(hass: HomeAssistant) -> None: - """Test serial number already exists from zeroconf but the discovery is ipv6.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "host": "1.1.1.1", - "name": "Envoy", - "username": "test-username", - "password": "test-password", - }, - unique_id="1234", - title="Envoy", - ) - config_entry.add_to_hass(hass) +async def test_zeroconf_serial_already_exists_ignores_ipv6( + hass: HomeAssistant, config_entry, setup_enphase_envoy +) -> None: + """Test serial number already exists from zeroconf but the discovery is ipv6.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -357,71 +267,39 @@ async def test_zeroconf_serial_already_exists_ignores_ipv6(hass: HomeAssistant) type="mock_type", ), ) - assert result["type"] == "abort" assert result["reason"] == "not_ipv4_address" - assert config_entry.data[CONF_HOST] == "1.1.1.1" + assert config_entry.data["host"] == "1.1.1.1" -async def test_zeroconf_host_already_exists(hass: HomeAssistant) -> None: - """Test hosts already exists from zeroconf.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "host": "1.1.1.1", - "name": "Envoy", - "username": "test-username", - "password": "test-password", - }, - title="Envoy", +@pytest.mark.parametrize("serial_number", [None]) +async def test_zeroconf_host_already_exists( + hass: HomeAssistant, config_entry, setup_enphase_envoy +) -> None: + """Test hosts already exists from zeroconf.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + addresses=["1.1.1.1"], + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"serialnum": "1234"}, + type="mock_type", + ), ) - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ), patch( - "homeassistant.components.enphase_envoy.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serialnum": "1234"}, - type="mock_type", - ), - ) - await hass.async_block_till_done() - assert result["type"] == "abort" assert result["reason"] == "already_configured" assert config_entry.unique_id == "1234" assert config_entry.title == "Envoy 1234" - assert len(mock_setup_entry.mock_calls) == 1 -async def test_reauth(hass: HomeAssistant) -> None: +async def test_reauth(hass: HomeAssistant, config_entry, setup_enphase_envoy) -> None: """Test we reauth auth.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "host": "1.1.1.1", - "name": "Envoy", - "username": "test-username", - "password": "test-password", - }, - title="Envoy", - ) - config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={ @@ -430,19 +308,13 @@ async def test_reauth(hass: HomeAssistant) -> None: "entry_id": config_entry.entry_id, }, ) - - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", - }, - ) - + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) assert result2["type"] == "abort" assert result2["reason"] == "reauth_successful" diff --git a/tests/components/enphase_envoy/test_diagnostics.py b/tests/components/enphase_envoy/test_diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..caa7d66fc95c73066414bff1165cfef957295d61 --- /dev/null +++ b/tests/components/enphase_envoy/test_diagnostics.py @@ -0,0 +1,56 @@ +"""Test Enphase Envoy diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_enphase_envoy): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "enphase_envoy", + "title": REDACTED, + "data": { + "host": "1.1.1.1", + "name": REDACTED, + "username": REDACTED, + "password": REDACTED, + }, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + }, + "data": { + "production": 1840, + "daily_production": 28223, + "seven_days_production": 174482, + "lifetime_production": 5924391, + "consumption": 1840, + "daily_consumption": 5923857, + "seven_days_consumption": 5923857, + "lifetime_consumption": 5923857, + "inverters_production": { + "202140024014": [136, "2022-10-08 16:43:36"], + "202140023294": [163, "2022-10-08 16:43:41"], + "202140013819": [130, "2022-10-08 16:43:31"], + "202140023794": [139, "2022-10-08 16:43:38"], + "202140023381": [130, "2022-10-08 16:43:47"], + "202140024176": [54, "2022-10-08 16:43:59"], + "202140003284": [132, "2022-10-08 16:43:55"], + "202140019854": [129, "2022-10-08 16:43:58"], + "202140020743": [131, "2022-10-08 16:43:49"], + "202140023531": [28, "2022-10-08 16:43:53"], + "202140024241": [164, "2022-10-08 16:43:33"], + "202140022963": [164, "2022-10-08 16:43:41"], + "202140023149": [118, "2022-10-08 16:43:47"], + "202140024828": [129, "2022-10-08 16:43:36"], + "202140023269": [133, "2022-10-08 16:43:43"], + "202140024157": [112, "2022-10-08 16:43:52"], + }, + }, + } diff --git a/tests/components/fan/test_recorder.py b/tests/components/fan/test_recorder.py index 604f5e3a2e978593376d8919374ba70d7b607c6a..9a4dc14685ac6068aa25018dfe9a2912b1de7e6c 100644 --- a/tests/components/fan/test_recorder.py +++ b/tests/components/fan/test_recorder.py @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test fan registered attributes to be excluded.""" await async_setup_component(hass, fan.DOMAIN, {fan.DOMAIN: {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index e6c42d370e6a613753d02d2797a18284a05bdb31..b440ac7889b262644463025b67239c7b8c65d4ea 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -59,7 +59,7 @@ async def test_setup_fail(hass): await hass.async_block_till_done() -async def test_chain(hass, recorder_mock, values): +async def test_chain(recorder_mock, hass, values): """Test if filter chaining works.""" config = { "sensor": { @@ -87,7 +87,7 @@ async def test_chain(hass, recorder_mock, values): @pytest.mark.parametrize("missing", (True, False)) -async def test_chain_history(hass, recorder_mock, values, missing): +async def test_chain_history(recorder_mock, hass, values, missing): """Test if filter chaining works, when a source is and isn't recorded.""" config = { "sensor": { @@ -141,7 +141,7 @@ async def test_chain_history(hass, recorder_mock, values, missing): assert state.state == "17.05" -async def test_source_state_none(hass, recorder_mock, values): +async def test_source_state_none(recorder_mock, hass, values): """Test is source sensor state is null and sets state to STATE_UNKNOWN.""" config = { @@ -201,7 +201,7 @@ async def test_source_state_none(hass, recorder_mock, values): assert state.state == STATE_UNKNOWN -async def test_history_time(hass, recorder_mock): +async def test_history_time(recorder_mock, hass): """Test loading from history based on a time window.""" config = { "sensor": { @@ -239,7 +239,7 @@ async def test_history_time(hass, recorder_mock): assert state.state == "18.0" -async def test_setup(hass, recorder_mock): +async def test_setup(recorder_mock, hass): """Test if filter attributes are inherited.""" config = { "sensor": { @@ -280,7 +280,7 @@ async def test_setup(hass, recorder_mock): assert entity_id == "sensor.test" -async def test_invalid_state(hass, recorder_mock): +async def test_invalid_state(recorder_mock, hass): """Test if filter attributes are inherited.""" config = { "sensor": { @@ -310,7 +310,7 @@ async def test_invalid_state(hass, recorder_mock): assert state.state == STATE_UNAVAILABLE -async def test_timestamp_state(hass, recorder_mock): +async def test_timestamp_state(recorder_mock, hass): """Test if filter state is a datetime.""" config = { "sensor": { @@ -469,7 +469,7 @@ def test_time_sma(values): assert filtered.state == 21.5 -async def test_reload(hass, recorder_mock): +async def test_reload(recorder_mock, hass): """Verify we can reload filter sensors.""" hass.states.async_set("sensor.test_monitored", 12345) await async_setup_component( diff --git a/tests/components/fjaraskupan/__init__.py b/tests/components/fjaraskupan/__init__.py index 94acad4df5a3f0970e575c71f0351f701a58c3f2..d4014ea8657a08e651f4c12cbb58f41fa8b95da3 100644 --- a/tests/components/fjaraskupan/__init__.py +++ b/tests/components/fjaraskupan/__init__.py @@ -2,10 +2,11 @@ from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from tests.components.bluetooth import generate_advertisement_data + COOKER_SERVICE_INFO = BluetoothServiceInfoBleak( name="COOKERHOOD_FJAR", address="AA:BB:CC:DD:EE:FF", @@ -15,7 +16,7 @@ COOKER_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="COOKERHOOD_FJAR"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) diff --git a/tests/components/flipr/test_sensor.py b/tests/components/flipr/test_sensor.py index 30468064daed8c7621e4b61d84ff538b09eb0f8f..1b8a1928b1fa9b6a41a6d00fedb49d4e978f3073 100644 --- a/tests/components/flipr/test_sensor.py +++ b/tests/components/flipr/test_sensor.py @@ -69,7 +69,7 @@ async def test_sensors(hass: HomeAssistant) -> None: 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_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.state == "10.5" diff --git a/tests/components/flo/test_sensor.py b/tests/components/flo/test_sensor.py index c3266a84bd8b3589b9b2325a58dd6c802c52bd3e..63cd2b97e70569d4b3175923cff851bcf3ee9242 100644 --- a/tests/components/flo/test_sensor.py +++ b/tests/components/flo/test_sensor.py @@ -3,12 +3,14 @@ from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.setup import async_setup_component +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .common import TEST_PASSWORD, TEST_USER_ID async def test_sensors(hass, config_entry, aioclient_mock_fixture): """Test Flo by Moen sensors.""" + hass.config.units = US_CUSTOMARY_SYSTEM config_entry.add_to_hass(hass) assert await async_setup_component( hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} @@ -49,7 +51,7 @@ async def test_sensors(hass, config_entry, aioclient_mock_fixture): == SensorStateClass.MEASUREMENT ) - assert hass.states.get("sensor.smart_water_shutoff_water_temperature").state == "21" + assert hass.states.get("sensor.smart_water_shutoff_water_temperature").state == "70" assert ( hass.states.get("sensor.smart_water_shutoff_water_temperature").attributes[ ATTR_STATE_CLASS @@ -58,7 +60,7 @@ async def test_sensors(hass, config_entry, aioclient_mock_fixture): ) # and 3 entities for the detector - assert hass.states.get("sensor.kitchen_sink_temperature").state == "16" + assert hass.states.get("sensor.kitchen_sink_temperature").state == "61" assert ( hass.states.get("sensor.kitchen_sink_temperature").attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT diff --git a/tests/components/forked_daapd/test_browse_media.py b/tests/components/forked_daapd/test_browse_media.py index ba0473513b9f0479874d26da774d3652a639f96f..957c52a88c5f46b5af709e52c414ce9cbc297fd1 100644 --- a/tests/components/forked_daapd/test_browse_media.py +++ b/tests/components/forked_daapd/test_browse_media.py @@ -16,7 +16,7 @@ from homeassistant.components.spotify.const import ( from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.setup import async_setup_component -TEST_MASTER_ENTITY_NAME = "media_player.forked_daapd_server" +TEST_MASTER_ENTITY_NAME = "media_player.owntone_server" async def test_async_browse_media(hass, hass_ws_client, config_entry): @@ -255,7 +255,7 @@ async def test_async_browse_spotify(hass, hass_ws_client, config_entry): assert await async_setup_component(hass, spotify.DOMAIN, {}) await hass.async_block_till_done() config_entry.add_to_hass(hass) - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() with patch( "homeassistant.components.forked_daapd.media_player.spotify_async_browse_media" @@ -299,6 +299,52 @@ async def test_async_browse_spotify(hass, hass_ws_client, config_entry): assert msg["success"] +async def test_async_browse_media_source(hass, hass_ws_client, config_entry): + """Test browsing media_source.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + with patch( + "homeassistant.components.forked_daapd.media_player.media_source.async_browse_media" + ) as mock_media_source_browse: + children = [ + BrowseMedia( + title="Test mp3", + media_class=MediaClass.MUSIC, + media_content_id="media-source://test_dir/test.mp3", + media_content_type="audio/aac", + can_play=False, + can_expand=True, + ) + ] + mock_media_source_browse.return_value = BrowseMedia( + title="Audio Folder", + media_class=MediaClass.DIRECTORY, + media_content_id="media-source://audio_folder", + media_content_type=MediaType.APP, + can_play=False, + can_expand=True, + children=children, + ) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": TEST_MASTER_ENTITY_NAME, + "media_content_type": MediaType.APP, + "media_content_id": "media-source://audio_folder", + } + ) + msg = await client.receive_json() + # Assert WebSocket response + assert msg["id"] == 1 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + async def test_async_browse_image(hass, hass_client, config_entry): """Test browse media images.""" diff --git a/tests/components/forked_daapd/test_media_player.py b/tests/components/forked_daapd/test_media_player.py index 589f176db1433553912582a832e37fab1cecdbaa..ae5e29bee472ed223cd8c1978fe9f14786b5f46e 100644 --- a/tests/components/forked_daapd/test_media_player.py +++ b/tests/components/forked_daapd/test_media_player.py @@ -66,10 +66,9 @@ from homeassistant.const import ( from tests.common import async_mock_signal -TEST_MASTER_ENTITY_NAME = "media_player.forked_daapd_server" +TEST_MASTER_ENTITY_NAME = "media_player.owntone_server" TEST_ZONE_ENTITY_NAMES = [ - "media_player.forked_daapd_output_" + x - for x in ("kitchen", "computer", "daapd_fifo") + "media_player.owntone_output_" + x for x in ("kitchen", "computer", "daapd_fifo") ] OPTIONS_DATA = { @@ -354,7 +353,7 @@ def test_master_state(hass, mock_api_object): """Test master state attributes.""" state = hass.states.get(TEST_MASTER_ENTITY_NAME) assert state.state == STATE_PAUSED - assert state.attributes[ATTR_FRIENDLY_NAME] == "forked-daapd server" + assert state.attributes[ATTR_FRIENDLY_NAME] == "Owntone server" assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORTED_FEATURES assert not state.attributes[ATTR_MEDIA_VOLUME_MUTED] assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.2 @@ -413,7 +412,7 @@ async def test_zone(hass, mock_api_object): """Test zone attributes and methods.""" zone_entity_name = TEST_ZONE_ENTITY_NAMES[0] state = hass.states.get(zone_entity_name) - assert state.attributes[ATTR_FRIENDLY_NAME] == "forked-daapd output (kitchen)" + assert state.attributes[ATTR_FRIENDLY_NAME] == "Owntone output (kitchen)" assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORTED_FEATURES_ZONE assert state.state == STATE_ON assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.5 diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 0759beb68498c99c2c9d1a69fd90d75573c4daa2..8078722246e1e33a12b6bf1574f38e99ca118b80 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -1,6 +1,6 @@ """Tests for AVM Fritz!Box light component.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, call from requests.exceptions import HTTPError @@ -11,8 +11,10 @@ from homeassistant.components.fritzbox.const import ( ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, DOMAIN, ) from homeassistant.const import ( @@ -24,7 +26,6 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.util import color import homeassistant.util.dt as dt_util from . import FritzDeviceLightMock, setup_config_entry @@ -53,9 +54,9 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state assert state.state == STATE_ON assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_COLOR_TEMP] == color.color_temperature_kelvin_to_mired( - 2700 - ) + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2700 + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 async def test_setup_color(hass: HomeAssistant, fritz: Mock): @@ -95,12 +96,14 @@ async def test_turn_on(hass: HomeAssistant, fritz: Mock): assert await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_COLOR_TEMP: 300}, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_COLOR_TEMP_KELVIN: 3000}, True, ) assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color_temp.call_count == 1 + assert device.set_color_temp.call_args_list == [call(3000)] + assert device.set_level.call_args_list == [call(100)] async def test_turn_on_color(hass: HomeAssistant, fritz: Mock): @@ -122,6 +125,10 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock): assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_unmapped_color.call_count == 1 + assert device.set_level.call_args_list == [call(100)] + assert device.set_unmapped_color.call_args_list == [ + call((100, round(70 * 255.0 / 100.0))) + ] async def test_turn_on_color_unsupported_api_method(hass: HomeAssistant, fritz: Mock): @@ -150,6 +157,8 @@ async def test_turn_on_color_unsupported_api_method(hass: HomeAssistant, fritz: assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color.call_count == 1 + assert device.set_level.call_args_list == [call(100)] + assert device.set_color.call_args_list == [call((100, 70))] async def test_turn_off(hass: HomeAssistant, fritz: Mock): diff --git a/tests/components/fully_kiosk/test_button.py b/tests/components/fully_kiosk/test_button.py index 8616d7107f7c3a8b386f595eb5680fdeeca0e8cd..1c839e57dfd13cb569057ce7aa5b87aea3dbc0a5 100644 --- a/tests/components/fully_kiosk/test_button.py +++ b/tests/components/fully_kiosk/test_button.py @@ -22,31 +22,56 @@ async def test_buttons( entry = entity_registry.async_get("button.amazon_fire_restart_browser") assert entry assert entry.unique_id == "abcdef-123456-restartApp" - await call_service(hass, "press", "button.amazon_fire_restart_browser") + assert await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.amazon_fire_restart_browser"}, + blocking=True, + ) assert len(mock_fully_kiosk.restartApp.mock_calls) == 1 entry = entity_registry.async_get("button.amazon_fire_reboot_device") assert entry assert entry.unique_id == "abcdef-123456-rebootDevice" - await call_service(hass, "press", "button.amazon_fire_reboot_device") + assert await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.amazon_fire_reboot_device"}, + blocking=True, + ) assert len(mock_fully_kiosk.rebootDevice.mock_calls) == 1 entry = entity_registry.async_get("button.amazon_fire_bring_to_foreground") assert entry assert entry.unique_id == "abcdef-123456-toForeground" - await call_service(hass, "press", "button.amazon_fire_bring_to_foreground") + assert await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.amazon_fire_bring_to_foreground"}, + blocking=True, + ) assert len(mock_fully_kiosk.toForeground.mock_calls) == 1 entry = entity_registry.async_get("button.amazon_fire_send_to_background") assert entry assert entry.unique_id == "abcdef-123456-toBackground" - await call_service(hass, "press", "button.amazon_fire_send_to_background") + assert await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.amazon_fire_send_to_background"}, + blocking=True, + ) assert len(mock_fully_kiosk.toBackground.mock_calls) == 1 entry = entity_registry.async_get("button.amazon_fire_load_start_url") assert entry assert entry.unique_id == "abcdef-123456-loadStartUrl" - await call_service(hass, "press", "button.amazon_fire_load_start_url") + assert await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.amazon_fire_load_start_url"}, + blocking=True, + ) assert len(mock_fully_kiosk.loadStartUrl.mock_calls) == 1 assert entry.device_id @@ -60,10 +85,3 @@ async def test_buttons( assert device_entry.model == "KFDOWI" assert device_entry.name == "Amazon Fire" assert device_entry.sw_version == "1.42.5" - - -def call_service(hass, service, entity_id): - """Call any service on entity.""" - return hass.services.async_call( - button.DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True - ) diff --git a/tests/components/fully_kiosk/test_services.py b/tests/components/fully_kiosk/test_services.py new file mode 100644 index 0000000000000000000000000000000000000000..386bc542e3cc89961d29f979dc9a3cbfbe388c7f --- /dev/null +++ b/tests/components/fully_kiosk/test_services.py @@ -0,0 +1,47 @@ +"""Test Fully Kiosk Browser services.""" +from unittest.mock import MagicMock + +from homeassistant.components.fully_kiosk.const import ( + ATTR_APPLICATION, + ATTR_URL, + DOMAIN, + SERVICE_LOAD_URL, + SERVICE_START_APPLICATION, +) +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_services( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test the Fully Kiosk Browser services.""" + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "abcdef-123456")} + ) + + assert device_entry + + await hass.services.async_call( + DOMAIN, + SERVICE_LOAD_URL, + {ATTR_DEVICE_ID: [device_entry.id], ATTR_URL: "https://example.com"}, + blocking=True, + ) + + assert len(mock_fully_kiosk.loadUrl.mock_calls) == 1 + + await hass.services.async_call( + DOMAIN, + SERVICE_START_APPLICATION, + {ATTR_DEVICE_ID: [device_entry.id], ATTR_APPLICATION: "de.ozerov.fully"}, + blocking=True, + ) + + assert len(mock_fully_kiosk.startApplication.mock_calls) == 1 diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index d55141ebe5651f7e40d99ed595f4c18e7be4506d..83b9efde1e8ea3129de0e755b653facf49b3c25a 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -34,7 +34,7 @@ from homeassistant.const import ( from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry @@ -208,7 +208,7 @@ async def test_setup(hass): async def test_setup_imperial(hass): """Test the setup of the integration using imperial unit system.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( "1234", diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index 808e858b25972d817d3cb820e6ec01e35507bd9c..74679f050b6f4e03812e36037985d5b70d552ea0 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -78,7 +78,6 @@ def mock_create_stream(): @pytest.fixture async def user_flow(hass): """Initiate a user flow.""" - 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 f7e1898f735134562981f6da33de73ff131c8f5c..04c7fcca5b5c5be08086fc8462693ce1a6febb79 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -20,6 +20,7 @@ from homeassistant.components.generic.const import ( CONF_STREAM_SOURCE, DOMAIN, ) +from homeassistant.components.stream.const import CONF_RTSP_TRANSPORT 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 @@ -209,6 +210,7 @@ async def test_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_png CONF_VERIFY_SSL: False, CONF_USERNAME: "barney", CONF_PASSWORD: "betty", + CONF_RTSP_TRANSPORT: "http", }, ) mock_entry.add_to_hass(hass) diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index d303e064c1f91c97fb9994599cc50c6cd0dc5b23..ba4ff4dbd0c76f12d9d7dad449d3f609bae01004 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -1,8 +1,9 @@ """Test The generic (IP Camera) config flow.""" import errno +from http import HTTPStatus import os.path -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, PropertyMock, patch import httpx import pytest @@ -12,6 +13,7 @@ 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_CONFIRMED_OK, CONF_CONTENT_TYPE, CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, @@ -58,16 +60,30 @@ TESTDATA_YAML = { @respx.mock -async def test_form(hass, fakeimg_png, user_flow, mock_create_stream): +async def test_form(hass, fakeimgbytes_png, hass_client, user_flow, mock_create_stream): """Test the form with a normal set of settings.""" + respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) 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( + result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, ) + assert result1["type"] == data_entry_flow.FlowResultType.FORM + assert result1["step_id"] == "user_confirm_still" + client = await hass_client() + preview_id = result1["flow_id"] + # Check the preview image works. + resp = await client.get(f"/api/generic/preview_flow_image/{preview_id}?t=1") + assert resp.status == HTTPStatus.OK + assert await resp.read() == fakeimgbytes_png + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -83,6 +99,9 @@ async def test_form(hass, fakeimg_png, user_flow, mock_create_stream): } await hass.async_block_till_done() + # Check that the preview image is disabled after. + resp = await client.get(f"/api/generic/preview_flow_image/{preview_id}") + assert resp.status == HTTPStatus.NOT_FOUND assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -99,11 +118,17 @@ async def test_form_only_stillimage(hass, fakeimg_png, user_flow): data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( + result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data, ) await hass.async_block_till_done() + assert result1["type"] == data_entry_flow.FlowResultType.FORM + assert result1["step_id"] == "user_confirm_still" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -120,16 +145,65 @@ async def test_form_only_stillimage(hass, fakeimg_png, user_flow): assert respx.calls.call_count == 1 +@respx.mock +async def test_form_reject_still_preview( + hass, fakeimgbytes_png, mock_create_stream, user_flow +): + """Test we go back to the config screen if the user rejects the still preview.""" + respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) + with mock_create_stream: + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) + assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["step_id"] == "user_confirm_still" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: False}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + + +@respx.mock +async def test_form_still_preview_cam_off( + hass, fakeimg_png, mock_create_stream, user_flow, hass_client +): + """Test camera errors are triggered during preview.""" + with patch( + "homeassistant.components.generic.camera.GenericCamera.is_on", + new_callable=PropertyMock(return_value=False), + ), mock_create_stream: + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) + assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["step_id"] == "user_confirm_still" + preview_id = result1["flow_id"] + # Try to view the image, should be unavailable. + client = await hass_client() + resp = await client.get(f"/api/generic/preview_flow_image/{preview_id}?t=1") + assert resp.status == HTTPStatus.SERVICE_UNAVAILABLE + + @respx.mock 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) with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( + result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data, ) + assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["step_id"] == "user_confirm_still" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["options"][CONF_CONTENT_TYPE] == "image/gif" @@ -143,11 +217,17 @@ async def test_form_only_svg_whitespace(hass, fakeimgbytes_svg, user_flow): data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( + result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data, ) - await hass.async_block_till_done() + assert result1["type"] == data_entry_flow.FlowResultType.FORM + assert result1["step_id"] == "user_confirm_still" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY @@ -170,10 +250,16 @@ async def test_form_only_still_sample(hass, user_flow, image_file): data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( + result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data, ) + assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["step_id"] == "user_confirm_still" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY @@ -186,31 +272,31 @@ async def test_form_only_still_sample(hass, user_flow, image_file): ( "http://localhost:812{{3}}/static/icons/favicon-apple-180x180.png", "http://localhost:8123/static/icons/favicon-apple-180x180.png", - data_entry_flow.FlowResultType.CREATE_ENTRY, + "user_confirm_still", None, ), ( "{% if 1 %}https://bla{% else %}https://yo{% endif %}", "https://bla/", - data_entry_flow.FlowResultType.CREATE_ENTRY, + "user_confirm_still", None, ), ( "http://{{example.org", "http://example.org", - data_entry_flow.FlowResultType.FORM, + "user", {"still_image_url": "template_error"}, ), ( "invalid1://invalid:4\\1", "invalid1://invalid:4%5c1", - data_entry_flow.FlowResultType.FORM, + "user", {"still_image_url": "malformed_url"}, ), ( "relative/urls/are/not/allowed.jpg", "relative/urls/are/not/allowed.jpg", - data_entry_flow.FlowResultType.FORM, + "user", {"still_image_url": "relative_url"}, ), ], @@ -229,7 +315,7 @@ async def test_still_template( data, ) await hass.async_block_till_done() - assert result2["type"] == expected_result + assert result2["step_id"] == expected_result assert result2.get("errors") == expected_errors @@ -242,10 +328,15 @@ async def test_form_rtsp_mode(hass, fakeimg_png, user_flow, mock_create_stream): 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( + result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data ) - assert "errors" not in result2, f"errors={result2['errors']}" + assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["step_id"] == "user_confirm_still" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -265,21 +356,23 @@ async def test_form_rtsp_mode(hass, fakeimg_png, user_flow, mock_create_stream): assert len(mock_setup.mock_calls) == 1 -async def test_form_only_stream(hass, fakeimgbytes_jpg, mock_create_stream): +async def test_form_only_stream(hass, fakeimgbytes_jpg, user_flow, mock_create_stream): """Test we complete ok if the user wants stream only.""" - 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_create_stream as mock_setup: - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], data, ) + assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["step_id"] == "user_confirm_still" + result3 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) await hass.async_block_till_done() - assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result3["title"] == "127_0_0_1" assert result3["options"] == { @@ -503,7 +596,13 @@ async def test_options_template_error(hass, fakeimgbytes_png, mock_create_stream result["flow_id"], user_input=data, ) - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "confirm_still" + + result2a = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} + ) + assert result2a["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY result3 = await hass.config_entries.options.async_init(mock_entry.entry_id) assert result3["type"] == data_entry_flow.FlowResultType.FORM @@ -588,10 +687,16 @@ async def test_options_only_stream(hass, fakeimgbytes_png, mock_create_stream): # try updating the config options with mock_create_stream: - result3 = await hass.config_entries.options.async_configure( + result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input=data, ) + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "confirm_still" + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} + ) assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" @@ -716,4 +821,24 @@ async def test_use_wallclock_as_timestamps_option( result["flow_id"], user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, ) - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == data_entry_flow.FlowResultType.FORM + # Test what happens if user rejects the preview + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={CONF_CONFIRMED_OK: False} + ) + assert result3["type"] == data_entry_flow.FlowResultType.FORM + assert result3["step_id"] == "init" + with patch( + "homeassistant.components.generic.async_setup_entry", return_value=True + ), mock_create_stream: + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + ) + assert result4["type"] == data_entry_flow.FlowResultType.FORM + assert result4["step_id"] == "confirm_still" + result5 = await hass.config_entries.options.async_configure( + result4["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + assert result5["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index f6a8795a29db95c3bf2faf6edf01538fbf8983d1..52714ab15a2d03960c726f62c8747946dab318f8 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -37,7 +37,7 @@ from homeassistant.core import DOMAIN as HASS_DOMAIN, CoreState, State, callback from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.common import ( assert_setup_component, @@ -1201,7 +1201,7 @@ async def setup_comp_9(hass): async def test_precision(hass, setup_comp_9): """Test that setting precision to tenths works as intended.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await common.async_set_temperature(hass, 23.27) state = hass.states.get(ENTITY) assert state.attributes.get("temperature") == 23.3 diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index 130964b4eebe46d3d10e6276e64bd59feac2dead..327829d3d4bc266808df74a84b86851a4e51e3f1 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -28,7 +28,7 @@ from homeassistant.const import ( from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry @@ -171,7 +171,7 @@ async def test_setup(hass): async def test_setup_imperial(hass): """Test the setup of the integration using imperial unit system.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (38.0, -3.0)) diff --git a/tests/components/geonetnz_volcano/test_sensor.py b/tests/components/geonetnz_volcano/test_sensor.py index dbb6834c596245b80c3f46440eae0f631e015cf3..9b53cb9cc9b01442ba66aab1fdd997431c2952c4 100644 --- a/tests/components/geonetnz_volcano/test_sensor.py +++ b/tests/components/geonetnz_volcano/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry @@ -150,7 +150,7 @@ async def test_setup(hass): async def test_setup_imperial(hass): """Test the setup of the integration using imperial unit system.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 1, 15.5, (38.0, -3.0)) diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index e6b7c26ca84896071a020fd6286a394342686327..2f5efd829bf983b1d3ec81f1b1ad6d256518696a 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -206,9 +206,13 @@ def mock_events_list( ) -> None: if calendar_id is None: calendar_id = CALENDAR_ID + resp = { + **response, + "nextSyncToken": "sync-token", + } aioclient_mock.get( f"{API_BASE_URL}/calendars/{calendar_id}/events", - json=response, + json=resp, exc=exc, ) return @@ -236,9 +240,13 @@ def mock_calendars_list( """Fixture to construct a fake calendar list API response.""" def _result(response: dict[str, Any], exc: ClientError | None = None) -> None: + resp = { + **response, + "nextSyncToken": "sync-token", + } aioclient_mock.get( f"{API_BASE_URL}/users/me/calendarList", - json=response, + json=resp, exc=exc, ) return diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index f4129eb09267166ab5b6acfb02ec1566d1af349b..3bd584f4c6fa72d72daef0301b1c25e9d3c00ae3 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -2,7 +2,6 @@ from __future__ import annotations -import copy import datetime from http import HTTPStatus from typing import Any @@ -10,7 +9,6 @@ from unittest.mock import patch import urllib from aiohttp.client_exceptions import ClientError -from gcal_sync.auth import API_BASE_URL import pytest from homeassistant.components.google.const import DOMAIN @@ -24,10 +22,10 @@ from .conftest import ( TEST_API_ENTITY, TEST_API_ENTITY_NAME, TEST_YAML_ENTITY, + TEST_YAML_ENTITY_NAME, ) from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMockResponse TEST_ENTITY = TEST_API_ENTITY TEST_ENTITY_NAME = TEST_API_ENTITY_NAME @@ -75,6 +73,11 @@ def mock_test_setup( return +def get_events_url(entity: str, start: str, end: str) -> str: + """Create a url to get events during the specified time range.""" + return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + + def upcoming() -> dict[str, Any]: """Create a test event with an arbitrary start/end time fetched from the api url.""" now = dt_util.now() @@ -84,21 +87,12 @@ def upcoming() -> dict[str, Any]: } -def upcoming_date() -> dict[str, Any]: - """Create a test event with an arbitrary start/end date fetched from the api url.""" - now = dt_util.now() - return { - "start": {"date": now.date().isoformat()}, - "end": {"date": now.date().isoformat()}, - } - - 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/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + return get_events_url(entity, start, end) async def test_all_day_event(hass, mock_events_list_items, component_setup): @@ -414,14 +408,12 @@ async def test_http_event_api_failure( aioclient_mock, ): """Test the Rest API response during a calendar failure.""" - mock_events_list({}) + mock_events_list({}, exc=ClientError()) + assert await component_setup() client = await hass_client() - aioclient_mock.clear_requests() - mock_events_list({}, exc=ClientError()) - response = await client.get(upcoming_event_url()) assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR @@ -462,7 +454,8 @@ async def test_http_api_all_day_event( """Test querying the API and fetching events from the server.""" event = { **TEST_EVENT, - **upcoming_date(), + "start": {"date": "2022-03-27"}, + "end": {"date": "2022-03-28"}, } mock_events_list_items([event]) assert await component_setup() @@ -475,70 +468,10 @@ async def test_http_api_all_day_event( assert {k: events[0].get(k) for k in ["summary", "start", "end"]} == { "summary": TEST_EVENT["summary"], "start": {"date": "2022-03-27"}, - "end": {"date": "2022-03-27"}, + "end": {"date": "2022-03-28"}, } -@pytest.mark.freeze_time("2022-03-27 12:05:00+00:00") -async def test_http_api_event_paging( - hass, hass_client, aioclient_mock, component_setup -): - """Test paging through results from the server.""" - hass.config.set_time_zone("Asia/Baghdad") - - responses = [ - { - "nextPageToken": "page-token", - "items": [ - { - **TEST_EVENT, - "summary": "event 1", - **upcoming(), - } - ], - }, - { - "items": [ - { - **TEST_EVENT, - "summary": "event 2", - **upcoming(), - } - ], - }, - ] - - def next_response(response_list): - results = copy.copy(response_list) - - async def get(method, url, data): - return AiohttpClientMockResponse(method, url, json=results.pop(0)) - - return get - - # Setup response for initial entity load - aioclient_mock.get( - f"{API_BASE_URL}/calendars/{CALENDAR_ID}/events", - side_effect=next_response(responses), - ) - assert await component_setup() - - # Setup response for API request - aioclient_mock.clear_requests() - aioclient_mock.get( - f"{API_BASE_URL}/calendars/{CALENDAR_ID}/events", - side_effect=next_response(responses), - ) - - client = await hass_client() - response = await client.get(upcoming_event_url()) - assert response.status == HTTPStatus.OK - events = await response.json() - assert len(events) == 2 - assert events[0]["summary"] == "event 1" - assert events[1]["summary"] == "event 2" - - @pytest.mark.parametrize( "calendars_config_ignore_availability,transparency,expect_visible_event", [ @@ -577,6 +510,11 @@ async def test_opaque_event( events = await response.json() assert (len(events) > 0) == expect_visible_event + # Verify entity state for upcoming event + state = hass.states.get(TEST_YAML_ENTITY) + assert state.name == TEST_YAML_ENTITY_NAME + assert state.state == (STATE_ON if expect_visible_event else STATE_OFF) + @pytest.mark.parametrize("mock_test_setup", [None]) async def test_scan_calendar_error( @@ -783,3 +721,58 @@ async def test_invalid_unique_id_cleanup( entity_registry, config_entry.entry_id ) assert not registry_entries + + +@pytest.mark.parametrize( + "time_zone,event_order", + [ + ("America/Los_Angeles", ["One", "Two", "All Day Event"]), + ("America/Regina", ["One", "Two", "All Day Event"]), + ("UTC", ["One", "All Day Event", "Two"]), + ("Asia/Tokyo", ["All Day Event", "One", "Two"]), + ], +) +async def test_all_day_iter_order( + hass, + hass_client, + mock_events_list_items, + component_setup, + time_zone, + event_order, +): + """Test the sort order of an all day events depending on the time zone.""" + hass.config.set_time_zone(time_zone) + mock_events_list_items( + [ + { + **TEST_EVENT, + "id": "event-id-3", + "summary": "All Day Event", + "start": {"date": "2022-10-08"}, + "end": {"date": "2022-10-09"}, + }, + { + **TEST_EVENT, + "id": "event-id-1", + "summary": "One", + "start": {"dateTime": "2022-10-07T23:00:00+00:00"}, + "end": {"dateTime": "2022-10-07T23:30:00+00:00"}, + }, + { + **TEST_EVENT, + "id": "event-id-2", + "summary": "Two", + "start": {"dateTime": "2022-10-08T01:00:00+00:00"}, + "end": {"dateTime": "2022-10-08T02:00:00+00:00"}, + }, + ] + ) + assert await component_setup() + + client = await hass_client() + response = await client.get( + get_events_url(TEST_ENTITY, "2022-10-06T00:00:00Z", "2022-10-09T00:00:00Z") + ) + assert response.status == HTTPStatus.OK + events = await response.json() + assert [event["summary"] for event in events] == event_order diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 613aa6dbb707fdae345fd5fb0ec4e0016d9540a5..5e7696eec6881e8b8a2fa2f22366b891b07a914c 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -818,3 +818,24 @@ async def test_assign_unique_id_failure( assert config_entry.state is config_entry_status assert config_entry.unique_id is None + + +async def test_remove_entry( + hass: HomeAssistant, + mock_calendars_list: ApiResult, + component_setup: ComponentSetup, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, +) -> None: + """Test load and remove of a ConfigEntry.""" + 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.LOADED + + assert await hass.config_entries.async_remove(entry.entry_id) + assert entry.state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/google_travel_time/conftest.py b/tests/components/google_travel_time/conftest.py index 4ca7c5a91055fe2aa53f5755fc8f8c0905e6d2fd..ec5a8f1691745d7f75bbc53aa32af858a4c9cd36 100644 --- a/tests/components/google_travel_time/conftest.py +++ b/tests/components/google_travel_time/conftest.py @@ -1,7 +1,7 @@ """Fixtures for Google Time Travel tests.""" from unittest.mock import patch -from googlemaps.exceptions import ApiError +from googlemaps.exceptions import ApiError, Timeout, TransportError import pytest from homeassistant.components.google_travel_time.const import DOMAIN @@ -58,3 +58,21 @@ def validate_config_entry_fixture(): def invalidate_config_entry_fixture(validate_config_entry): """Return invalid config entry.""" validate_config_entry.side_effect = ApiError("test") + + +@pytest.fixture(name="invalid_api_key") +def invalid_api_key_fixture(validate_config_entry): + """Throw a REQUEST_DENIED ApiError.""" + validate_config_entry.side_effect = ApiError("REQUEST_DENIED", "Invalid API key.") + + +@pytest.fixture(name="timeout") +def timeout_fixture(validate_config_entry): + """Throw a Timeout exception.""" + validate_config_entry.side_effect = Timeout() + + +@pytest.fixture(name="transport_error") +def transport_error_fixture(validate_config_entry): + """Throw a TransportError exception.""" + validate_config_entry.side_effect = TransportError("Unknown.") diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 4fe5f797d4522e29643557938423d9b2a90f5290..9ddcee5cdacf8d8a3fda47af71b4bbaf01de9868 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -19,13 +19,9 @@ from homeassistant.components.google_travel_time.const import ( DEFAULT_NAME, DEPARTURE_TIME, DOMAIN, + UNITS_IMPERIAL, ) -from homeassistant.const import ( - CONF_API_KEY, - CONF_MODE, - CONF_NAME, - CONF_UNIT_SYSTEM_IMPERIAL, -) +from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME from .const import MOCK_CONFIG @@ -71,6 +67,73 @@ async def test_invalid_config_entry(hass): assert result2["errors"] == {"base": "cannot_connect"} +@pytest.mark.usefixtures("invalid_api_key") +async def test_invalid_api_key(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.FlowResultType.FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +@pytest.mark.usefixtures("transport_error") +async def test_transport_error(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.FlowResultType.FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +@pytest.mark.usefixtures("timeout") +async def test_timeout(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.FlowResultType.FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_malformed_api_key(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.FlowResultType.FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + @pytest.mark.parametrize( "data,options", [ @@ -78,8 +141,7 @@ async def test_invalid_config_entry(hass): MOCK_CONFIG, { CONF_MODE: "driving", - CONF_ARRIVAL_TIME: "test", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, }, ) ], @@ -100,7 +162,7 @@ async def test_options_flow(hass, mock_config): CONF_MODE: "driving", CONF_LANGUAGE: "en", CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: ARRIVAL_TIME, CONF_TIME: "test", CONF_TRAFFIC_MODEL: "best_guess", @@ -114,7 +176,7 @@ async def test_options_flow(hass, mock_config): CONF_MODE: "driving", CONF_LANGUAGE: "en", CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, CONF_ARRIVAL_TIME: "test", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", @@ -125,7 +187,7 @@ async def test_options_flow(hass, mock_config): CONF_MODE: "driving", CONF_LANGUAGE: "en", CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, CONF_ARRIVAL_TIME: "test", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", @@ -135,7 +197,15 @@ async def test_options_flow(hass, mock_config): @pytest.mark.parametrize( "data,options", - [(MOCK_CONFIG, {})], + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + }, + ) + ], ) @pytest.mark.usefixtures("validate_config_entry") async def test_options_flow_departure_time(hass, mock_config): @@ -153,7 +223,7 @@ async def test_options_flow_departure_time(hass, mock_config): CONF_MODE: "driving", CONF_LANGUAGE: "en", CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, CONF_TIME_TYPE: DEPARTURE_TIME, CONF_TIME: "test", CONF_TRAFFIC_MODEL: "best_guess", @@ -167,7 +237,7 @@ async def test_options_flow_departure_time(hass, mock_config): CONF_MODE: "driving", CONF_LANGUAGE: "en", CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, CONF_DEPARTURE_TIME: "test", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", @@ -178,7 +248,7 @@ async def test_options_flow_departure_time(hass, mock_config): CONF_MODE: "driving", CONF_LANGUAGE: "en", CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: UNITS_IMPERIAL, CONF_DEPARTURE_TIME: "test", CONF_TRAFFIC_MODEL: "best_guess", CONF_TRANSIT_MODE: "train", diff --git a/tests/components/google_travel_time/test_init.py b/tests/components/google_travel_time/test_init.py deleted file mode 100644 index 583cd4dc7ce994bb6b96251f91f03c8bfad6e4f0..0000000000000000000000000000000000000000 --- a/tests/components/google_travel_time/test_init.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Test Google Maps Travel Time initialization.""" -from homeassistant.components.google_travel_time.const import DOMAIN -from homeassistant.helpers.entity_registry import async_get - -from tests.common import MockConfigEntry - - -async def test_migration(hass, bypass_platform_setup): - """Test migration logic for unique id.""" - config_entry = MockConfigEntry( - domain=DOMAIN, version=1, entry_id="test", unique_id="test" - ) - ent_reg = async_get(hass) - ent_entry = ent_reg.async_get_or_create( - "sensor", DOMAIN, unique_id="replaceable_unique_id", config_entry=config_entry - ) - entity_id = ent_entry.entity_id - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.unique_id is None - assert ent_reg.async_get(entity_id).unique_id == config_entry.entry_id diff --git a/tests/components/google_travel_time/test_sensor.py b/tests/components/google_travel_time/test_sensor.py index daedcfef4c18f49085353e1955ee2f54abcb0fbb..d0a94712fcbd16dfd74cc4d8135b8ca03467c5f5 100644 --- a/tests/components/google_travel_time/test_sensor.py +++ b/tests/components/google_travel_time/test_sensor.py @@ -4,12 +4,19 @@ from unittest.mock import patch import pytest +from homeassistant.components.google_travel_time.config_flow import default_options from homeassistant.components.google_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, - CONF_TRAVEL_MODE, DOMAIN, ) +from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from .const import MOCK_CONFIG @@ -195,26 +202,33 @@ async def test_sensor_arrival_time_custom_timestamp(hass): assert hass.states.get("sensor.google_travel_time").state == "27" -@pytest.mark.usefixtures("mock_update") -async def test_sensor_deprecation_warning(hass, caplog): - """Test that sensor setup prints a deprecating warning for old configs. +@pytest.mark.parametrize( + "unit_system, expected_unit_option", + [ + (METRIC_SYSTEM, CONF_UNIT_SYSTEM_METRIC), + (US_CUSTOMARY_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL), + ], +) +async def test_sensor_unit_system( + hass: HomeAssistant, + unit_system: UnitSystem, + expected_unit_option: str, +) -> None: + """Test that sensor works.""" + hass.config.units = unit_system - The mock_config fixture does not work with caplog. - """ - data = MOCK_CONFIG.copy() - data[CONF_TRAVEL_MODE] = "driving" config_entry = MockConfigEntry( domain=DOMAIN, - data=data, + data=MOCK_CONFIG, + options=default_options(hass), entry_id="test", ) config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.google_travel_time.sensor.Client"), patch( + "homeassistant.components.google_travel_time.sensor.distance_matrix" + ) as distance_matrix_mock: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - assert hass.states.get("sensor.google_travel_time").state == "27" - wstr = ( - "Google Travel Time: travel_mode is deprecated, please " - "add mode to the options dictionary instead!" - ) - assert wstr in caplog.text + distance_matrix_mock.assert_called_once() + assert distance_matrix_mock.call_args.kwargs["units"] == expected_unit_option diff --git a/tests/components/graphite/test_init.py b/tests/components/graphite/test_init.py index 23a25b1623e551dd1f18be8dedaf3abc7e15d889..19c9ebd61e381b35306c14fb0a779f46295e99a1 100644 --- a/tests/components/graphite/test_init.py +++ b/tests/components/graphite/test_init.py @@ -1,231 +1,281 @@ """The tests for the Graphite component.""" +import asyncio import socket -import unittest from unittest import mock from unittest.mock import patch -import homeassistant.components.graphite as graphite -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - EVENT_STATE_CHANGED, - STATE_OFF, - STATE_ON, +import pytest + +from homeassistant.components import graphite +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.setup import async_setup_component + + +@pytest.fixture(name="mock_gf") +def fixture_mock_gf(): + """Mock Graphite Feeder fixture.""" + with patch("homeassistant.components.graphite.GraphiteFeeder") as mock_gf: + yield mock_gf + + +@pytest.fixture(name="mock_socket") +def fixture_mock_socket(): + """Mock socket fixture.""" + with patch("socket.socket") as mock_socket: + yield mock_socket + + +@pytest.fixture(name="mock_time") +def fixture_mock_time(): + """Mock time fixture.""" + with patch("time.time") as mock_time: + yield mock_time + + +async def test_setup(hass, mock_socket): + """Test setup.""" + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + assert mock_socket.call_count == 1 + assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) + + +async def test_setup_failure(hass, mock_socket): + """Test setup fails due to socket error.""" + mock_socket.return_value.connect.side_effect = OSError + assert not await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + + assert mock_socket.call_count == 1 + assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) + assert mock_socket.return_value.connect.call_count == 1 + + +async def test_full_config(hass, mock_gf, mock_socket): + """Test setup with full configuration.""" + config = {"graphite": {"host": "foo", "port": 123, "prefix": "me"}} + + assert await async_setup_component(hass, graphite.DOMAIN, config) + assert mock_gf.call_count == 1 + assert mock_gf.call_args == mock.call(hass, "foo", 123, "tcp", "me") + assert mock_socket.call_count == 1 + assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) + + +async def test_full_udp_config(hass, mock_gf, mock_socket): + """Test setup with full configuration and UDP protocol.""" + config = { + "graphite": {"host": "foo", "port": 123, "protocol": "udp", "prefix": "me"} + } + + assert await async_setup_component(hass, graphite.DOMAIN, config) + assert mock_gf.call_count == 1 + assert mock_gf.call_args == mock.call(hass, "foo", 123, "udp", "me") + assert mock_socket.call_count == 0 + + +async def test_config_port(hass, mock_gf, mock_socket): + """Test setup with invalid port.""" + config = {"graphite": {"host": "foo", "port": 2003}} + + assert await async_setup_component(hass, graphite.DOMAIN, config) + assert mock_gf.called + assert mock_socket.call_count == 1 + assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) + + +async def test_start(hass, mock_socket, mock_time): + """Test the start.""" + mock_time.return_value = 12345 + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + await hass.async_block_till_done() + mock_socket.reset_mock() + + await hass.async_start() + + hass.states.async_set("test.entity", STATE_ON) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 1 + assert mock_socket.return_value.connect.call_args == mock.call(("localhost", 2003)) + assert mock_socket.return_value.sendall.call_count == 1 + assert mock_socket.return_value.sendall.call_args == mock.call( + b"ha.test.entity.state 1.000000 12345" + ) + assert mock_socket.return_value.send.call_count == 1 + assert mock_socket.return_value.send.call_args == mock.call(b"\n") + assert mock_socket.return_value.close.call_count == 1 + + +async def test_shutdown(hass, mock_socket, mock_time): + """Test the shutdown.""" + mock_time.return_value = 12345 + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + await hass.async_block_till_done() + mock_socket.reset_mock() + + await hass.async_start() + + hass.states.async_set("test.entity", STATE_ON) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 1 + assert mock_socket.return_value.connect.call_args == mock.call(("localhost", 2003)) + assert mock_socket.return_value.sendall.call_count == 1 + assert mock_socket.return_value.sendall.call_args == mock.call( + b"ha.test.entity.state 1.000000 12345" + ) + assert mock_socket.return_value.send.call_count == 1 + assert mock_socket.return_value.send.call_args == mock.call(b"\n") + assert mock_socket.return_value.close.call_count == 1 + + mock_socket.reset_mock() + + await hass.async_stop() + await hass.async_block_till_done() + + hass.states.async_set("test.entity", STATE_OFF) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 0 + assert mock_socket.return_value.sendall.call_count == 0 + + +async def test_report_attributes(hass, mock_socket, mock_time): + """Test the reporting with attributes.""" + attrs = {"foo": 1, "bar": 2.0, "baz": True, "bat": "NaN"} + expected = [ + "ha.test.entity.foo 1.000000 12345", + "ha.test.entity.bar 2.000000 12345", + "ha.test.entity.baz 1.000000 12345", + "ha.test.entity.state 1.000000 12345", + ] + + mock_time.return_value = 12345 + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + await hass.async_block_till_done() + mock_socket.reset_mock() + + await hass.async_start() + + hass.states.async_set("test.entity", STATE_ON, attrs) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 1 + assert mock_socket.return_value.connect.call_args == mock.call(("localhost", 2003)) + assert mock_socket.return_value.sendall.call_count == 1 + assert mock_socket.return_value.sendall.call_args == mock.call( + "\n".join(expected).encode("utf-8") + ) + assert mock_socket.return_value.send.call_count == 1 + assert mock_socket.return_value.send.call_args == mock.call(b"\n") + assert mock_socket.return_value.close.call_count == 1 + + +async def test_report_with_string_state(hass, mock_socket, mock_time): + """Test the reporting with strings.""" + expected = [ + "ha.test.entity.foo 1.000000 12345", + "ha.test.entity.state 1.000000 12345", + ] + + mock_time.return_value = 12345 + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + await hass.async_block_till_done() + mock_socket.reset_mock() + + await hass.async_start() + + hass.states.async_set("test.entity", "above_horizon", {"foo": 1.0}) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 1 + assert mock_socket.return_value.connect.call_args == mock.call(("localhost", 2003)) + assert mock_socket.return_value.sendall.call_count == 1 + assert mock_socket.return_value.sendall.call_args == mock.call( + "\n".join(expected).encode("utf-8") + ) + assert mock_socket.return_value.send.call_count == 1 + assert mock_socket.return_value.send.call_args == mock.call(b"\n") + assert mock_socket.return_value.close.call_count == 1 + + mock_socket.reset_mock() + + hass.states.async_set("test.entity", "not_float") + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 0 + assert mock_socket.return_value.sendall.call_count == 0 + assert mock_socket.return_value.send.call_count == 0 + assert mock_socket.return_value.close.call_count == 0 + + +async def test_report_with_binary_state(hass, mock_socket, mock_time): + """Test the reporting with binary state.""" + mock_time.return_value = 12345 + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + await hass.async_block_till_done() + mock_socket.reset_mock() + + await hass.async_start() + + expected = [ + "ha.test.entity.foo 1.000000 12345", + "ha.test.entity.state 1.000000 12345", + ] + hass.states.async_set("test.entity", STATE_ON, {"foo": 1.0}) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 1 + assert mock_socket.return_value.connect.call_args == mock.call(("localhost", 2003)) + assert mock_socket.return_value.sendall.call_count == 1 + assert mock_socket.return_value.sendall.call_args == mock.call( + "\n".join(expected).encode("utf-8") + ) + assert mock_socket.return_value.send.call_count == 1 + assert mock_socket.return_value.send.call_args == mock.call(b"\n") + assert mock_socket.return_value.close.call_count == 1 + + mock_socket.reset_mock() + + expected = [ + "ha.test.entity.foo 1.000000 12345", + "ha.test.entity.state 0.000000 12345", + ] + hass.states.async_set("test.entity", STATE_OFF, {"foo": 1.0}) + await asyncio.sleep(0.1) + + assert mock_socket.return_value.connect.call_count == 1 + assert mock_socket.return_value.connect.call_args == mock.call(("localhost", 2003)) + assert mock_socket.return_value.sendall.call_count == 1 + assert mock_socket.return_value.sendall.call_args == mock.call( + "\n".join(expected).encode("utf-8") + ) + assert mock_socket.return_value.send.call_count == 1 + assert mock_socket.return_value.send.call_args == mock.call(b"\n") + assert mock_socket.return_value.close.call_count == 1 + + +@pytest.mark.parametrize( + "error, log_text", + [ + (OSError, "Failed to send data to graphite"), + (socket.gaierror, "Unable to connect to host"), + (Exception, "Failed to process STATE_CHANGED event"), + ], ) -import homeassistant.core as ha -from homeassistant.setup import setup_component - -from tests.common import get_test_home_assistant - - -class TestGraphite(unittest.TestCase): - """Test the Graphite component.""" - - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.gf = graphite.GraphiteFeeder(self.hass, "foo", 123, "tcp", "ha") - - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - - @patch("socket.socket") - def test_setup(self, mock_socket): - """Test setup.""" - assert setup_component(self.hass, graphite.DOMAIN, {"graphite": {}}) - assert mock_socket.call_count == 1 - assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) - - @patch("socket.socket") - @patch("homeassistant.components.graphite.GraphiteFeeder") - def test_full_config(self, mock_gf, mock_socket): - """Test setup with full configuration.""" - config = {"graphite": {"host": "foo", "port": 123, "prefix": "me"}} - - assert setup_component(self.hass, graphite.DOMAIN, config) - assert mock_gf.call_count == 1 - assert mock_gf.call_args == mock.call(self.hass, "foo", 123, "tcp", "me") - assert mock_socket.call_count == 1 - assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) - - @patch("socket.socket") - @patch("homeassistant.components.graphite.GraphiteFeeder") - def test_full_udp_config(self, mock_gf, mock_socket): - """Test setup with full configuration and UDP protocol.""" - config = { - "graphite": {"host": "foo", "port": 123, "protocol": "udp", "prefix": "me"} - } - - assert setup_component(self.hass, graphite.DOMAIN, config) - assert mock_gf.call_count == 1 - assert mock_gf.call_args == mock.call(self.hass, "foo", 123, "udp", "me") - assert mock_socket.call_count == 0 - - @patch("socket.socket") - @patch("homeassistant.components.graphite.GraphiteFeeder") - def test_config_port(self, mock_gf, mock_socket): - """Test setup with invalid port.""" - config = {"graphite": {"host": "foo", "port": 2003}} - - assert setup_component(self.hass, graphite.DOMAIN, config) - assert mock_gf.called - assert mock_socket.call_count == 1 - assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) - - def test_subscribe(self): - """Test the subscription.""" - fake_hass = mock.MagicMock() - gf = graphite.GraphiteFeeder(fake_hass, "foo", 123, "tcp", "ha") - fake_hass.bus.listen_once.has_calls( - [ - mock.call(EVENT_HOMEASSISTANT_START, gf.start_listen), - mock.call(EVENT_HOMEASSISTANT_STOP, gf.shutdown), - ] - ) - assert fake_hass.bus.listen.call_count == 1 - assert fake_hass.bus.listen.call_args == mock.call( - EVENT_STATE_CHANGED, gf.event_listener - ) - - def test_start(self): - """Test the start.""" - with mock.patch.object(self.gf, "start") as mock_start: - self.gf.start_listen("event") - assert mock_start.call_count == 1 - assert mock_start.call_args == mock.call() - - def test_shutdown(self): - """Test the shutdown.""" - with mock.patch.object(self.gf, "_queue") as mock_queue: - self.gf.shutdown("event") - assert mock_queue.put.call_count == 1 - assert mock_queue.put.call_args == mock.call(self.gf._quit_object) - - def test_event_listener(self): - """Test the event listener.""" - with mock.patch.object(self.gf, "_queue") as mock_queue: - self.gf.event_listener("foo") - assert mock_queue.put.call_count == 1 - assert mock_queue.put.call_args == mock.call("foo") - - @patch("time.time") - def test_report_attributes(self, mock_time): - """Test the reporting with attributes.""" - mock_time.return_value = 12345 - attrs = {"foo": 1, "bar": 2.0, "baz": True, "bat": "NaN"} - - expected = [ - "ha.entity.state 0.000000 12345", - "ha.entity.foo 1.000000 12345", - "ha.entity.bar 2.000000 12345", - "ha.entity.baz 1.000000 12345", - ] - - state = mock.MagicMock(state=0, attributes=attrs) - with mock.patch.object(self.gf, "_send_to_graphite") as mock_send: - self.gf._report_attributes("entity", state) - actual = mock_send.call_args_list[0][0][0].split("\n") - assert sorted(expected) == sorted(actual) - - @patch("time.time") - def test_report_with_string_state(self, mock_time): - """Test the reporting with strings.""" - mock_time.return_value = 12345 - expected = ["ha.entity.foo 1.000000 12345", "ha.entity.state 1.000000 12345"] - - state = mock.MagicMock(state="above_horizon", attributes={"foo": 1.0}) - with mock.patch.object(self.gf, "_send_to_graphite") as mock_send: - self.gf._report_attributes("entity", state) - actual = mock_send.call_args_list[0][0][0].split("\n") - assert sorted(expected) == sorted(actual) - - @patch("time.time") - def test_report_with_binary_state(self, mock_time): - """Test the reporting with binary state.""" - mock_time.return_value = 12345 - state = ha.State("domain.entity", STATE_ON, {"foo": 1.0}) - with mock.patch.object(self.gf, "_send_to_graphite") as mock_send: - self.gf._report_attributes("entity", state) - expected = [ - "ha.entity.foo 1.000000 12345", - "ha.entity.state 1.000000 12345", - ] - actual = mock_send.call_args_list[0][0][0].split("\n") - assert sorted(expected) == sorted(actual) - - state.state = STATE_OFF - with mock.patch.object(self.gf, "_send_to_graphite") as mock_send: - self.gf._report_attributes("entity", state) - expected = [ - "ha.entity.foo 1.000000 12345", - "ha.entity.state 0.000000 12345", - ] - actual = mock_send.call_args_list[0][0][0].split("\n") - assert sorted(expected) == sorted(actual) - - @patch("time.time") - def test_send_to_graphite_errors(self, mock_time): - """Test the sending with errors.""" - mock_time.return_value = 12345 - state = ha.State("domain.entity", STATE_ON, {"foo": 1.0}) - with mock.patch.object(self.gf, "_send_to_graphite") as mock_send: - mock_send.side_effect = socket.error - self.gf._report_attributes("entity", state) - mock_send.side_effect = socket.gaierror - self.gf._report_attributes("entity", state) - - @patch("socket.socket") - def test_send_to_graphite(self, mock_socket): - """Test the sending of data.""" - self.gf._send_to_graphite("foo") - assert mock_socket.call_count == 1 - assert mock_socket.call_args == mock.call(socket.AF_INET, socket.SOCK_STREAM) - sock = mock_socket.return_value - assert sock.connect.call_count == 1 - assert sock.connect.call_args == mock.call(("foo", 123)) - assert sock.sendall.call_count == 1 - assert sock.sendall.call_args == mock.call(b"foo") - assert sock.send.call_count == 1 - assert sock.send.call_args == mock.call(b"\n") - assert sock.close.call_count == 1 - assert sock.close.call_args == mock.call() - - def test_run_stops(self): - """Test the stops.""" - with mock.patch.object(self.gf, "_queue") as mock_queue: - mock_queue.get.return_value = self.gf._quit_object - assert self.gf.run() is None - assert mock_queue.get.call_count == 1 - assert mock_queue.get.call_args == mock.call() - assert mock_queue.task_done.call_count == 1 - assert mock_queue.task_done.call_args == mock.call() - - def test_run(self): - """Test the running.""" - runs = [] - event = mock.MagicMock( - event_type=EVENT_STATE_CHANGED, - data={"entity_id": "entity", "new_state": mock.MagicMock()}, - ) - - def fake_get(): - if len(runs) >= 2: - return self.gf._quit_object - if runs: - runs.append(1) - return mock.MagicMock( - event_type="somethingelse", data={"new_event": None} - ) - runs.append(1) - return event - - with mock.patch.object(self.gf, "_queue") as mock_queue, mock.patch.object( - self.gf, "_report_attributes" - ) as mock_r: - mock_queue.get.side_effect = fake_get - self.gf.run() - # Twice for two events, once for the stop - assert mock_queue.task_done.call_count == 3 - assert mock_r.call_count == 1 - assert mock_r.call_args == mock.call("entity", event.data["new_state"]) +async def test_send_to_graphite_errors( + hass, mock_socket, mock_time, caplog, error, log_text +): + """Test the sending with errors.""" + mock_time.return_value = 12345 + assert await async_setup_component(hass, graphite.DOMAIN, {"graphite": {}}) + await hass.async_block_till_done() + mock_socket.reset_mock() + + await hass.async_start() + + mock_socket.return_value.connect.side_effect = error + + hass.states.async_set("test.entity", STATE_ON) + await asyncio.sleep(0.1) + + assert log_text in caplog.text diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index be50f29fe5cb8d6c0b15670996ca52db8c14d739..3ba4aaaad81a066f1fcce0b0d50f4e5647fa645c 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -13,12 +13,13 @@ from homeassistant.components.light import ( ATTR_COLOR_MODE, ATTR_COLOR_NAME, ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_FLASH, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, @@ -76,7 +77,7 @@ async def test_default_state(hass): assert state.attributes.get(ATTR_ENTITY_ID) == ["light.kitchen", "light.bedroom"] assert state.attributes.get(ATTR_BRIGHTNESS) is None assert state.attributes.get(ATTR_HS_COLOR) is None - assert state.attributes.get(ATTR_COLOR_TEMP) is None + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None assert state.attributes.get(ATTR_EFFECT_LIST) is None assert state.attributes.get(ATTR_EFFECT) is None @@ -685,7 +686,7 @@ async def test_color_temp(hass, enable_custom_integrations): entity0.supported_color_modes = {ColorMode.COLOR_TEMP} entity0.color_mode = ColorMode.COLOR_TEMP entity0.brightness = 255 - entity0.color_temp = 2 + entity0.color_temp_kelvin = 2 entity1 = platform.ENTITIES[1] entity1.supported_features = SUPPORT_COLOR_TEMP @@ -710,20 +711,20 @@ async def test_color_temp(hass, enable_custom_integrations): state = hass.states.get("light.light_group") assert state.attributes[ATTR_COLOR_MODE] == "color_temp" - assert state.attributes[ATTR_COLOR_TEMP] == 2 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2 assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] await hass.services.async_call( "light", "turn_on", - {"entity_id": [entity1.entity_id], ATTR_COLOR_TEMP: 1000}, + {"entity_id": [entity1.entity_id], ATTR_COLOR_TEMP_KELVIN: 1000}, blocking=True, ) await hass.async_block_till_done() state = hass.states.get("light.light_group") assert state.attributes[ATTR_COLOR_MODE] == "color_temp" - assert state.attributes[ATTR_COLOR_TEMP] == 501 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 501 assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] @@ -736,7 +737,7 @@ async def test_color_temp(hass, enable_custom_integrations): await hass.async_block_till_done() state = hass.states.get("light.light_group") assert state.attributes[ATTR_COLOR_MODE] == "color_temp" - assert state.attributes[ATTR_COLOR_TEMP] == 1000 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 1000 assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] @@ -819,14 +820,14 @@ async def test_min_max_mireds(hass, enable_custom_integrations): entity0 = platform.ENTITIES[0] entity0.supported_color_modes = {ColorMode.COLOR_TEMP} entity0.color_mode = ColorMode.COLOR_TEMP - entity0.color_temp = 2 - entity0.min_mireds = 2 - entity0.max_mireds = 5 + entity0.color_temp_kelvin = 2 + entity0._attr_min_color_temp_kelvin = 2 + entity0._attr_max_color_temp_kelvin = 5 entity1 = platform.ENTITIES[1] entity1.supported_features = SUPPORT_COLOR_TEMP - entity1.min_mireds = 1 - entity1.max_mireds = 1234567890 + entity1._attr_min_color_temp_kelvin = 1 + entity1._attr_max_color_temp_kelvin = 1234567890 assert await async_setup_component( hass, @@ -848,8 +849,8 @@ async def test_min_max_mireds(hass, enable_custom_integrations): await hass.async_block_till_done() state = hass.states.get("light.light_group") - assert state.attributes[ATTR_MIN_MIREDS] == 1 - assert state.attributes[ATTR_MAX_MIREDS] == 1234567890 + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 1 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 1234567890 await hass.services.async_call( "light", @@ -859,8 +860,8 @@ async def test_min_max_mireds(hass, enable_custom_integrations): ) await hass.async_block_till_done() state = hass.states.get("light.light_group") - assert state.attributes[ATTR_MIN_MIREDS] == 1 - assert state.attributes[ATTR_MAX_MIREDS] == 1234567890 + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 1 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 1234567890 await hass.services.async_call( "light", @@ -870,8 +871,8 @@ async def test_min_max_mireds(hass, enable_custom_integrations): ) await hass.async_block_till_done() state = hass.states.get("light.light_group") - assert state.attributes[ATTR_MIN_MIREDS] == 1 - assert state.attributes[ATTR_MAX_MIREDS] == 1234567890 + assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 1 + assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 1234567890 async def test_effect_list(hass): @@ -1448,7 +1449,7 @@ async def test_invalid_service_calls(hass): ATTR_BRIGHTNESS: 150, ATTR_XY_COLOR: (0.5, 0.42), ATTR_RGB_COLOR: (80, 120, 50), - ATTR_COLOR_TEMP: 1234, + ATTR_COLOR_TEMP_KELVIN: 1234, ATTR_EFFECT: "Sunshine", ATTR_TRANSITION: 4, ATTR_FLASH: "long", diff --git a/tests/components/group/test_recorder.py b/tests/components/group/test_recorder.py index 7a4a41839efc9427ce092c8b2489447e7612de01..0d89bd9a1e0f2645b685821607fa7663decaf8cd 100644 --- a/tests/components/group/test_recorder.py +++ b/tests/components/group/test_recorder.py @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test number registered attributes to be excluded.""" hass.states.async_set("light.bowl", STATE_ON) diff --git a/tests/components/guardian/conftest.py b/tests/components/guardian/conftest.py index 492a486f76d2522e31a37256ed8fea9f0136658a..c7bffee4fff046c9e1aca88aaa65fbf70bfedb14 100644 --- a/tests/components/guardian/conftest.py +++ b/tests/components/guardian/conftest.py @@ -32,31 +32,31 @@ def config_fixture(hass): } -@pytest.fixture(name="data_sensor_pair_dump", scope="session") +@pytest.fixture(name="data_sensor_pair_dump", scope="package") def data_sensor_pair_dump_fixture(): """Define data from a successful sensor_pair_dump response.""" return json.loads(load_fixture("sensor_pair_dump_data.json", "guardian")) -@pytest.fixture(name="data_sensor_pair_sensor", scope="session") +@pytest.fixture(name="data_sensor_pair_sensor", scope="package") def data_sensor_pair_sensor_fixture(): """Define data from a successful sensor_pair_sensor response.""" return json.loads(load_fixture("sensor_pair_sensor_data.json", "guardian")) -@pytest.fixture(name="data_sensor_paired_sensor_status", scope="session") +@pytest.fixture(name="data_sensor_paired_sensor_status", scope="package") def data_sensor_paired_sensor_status_fixture(): """Define data from a successful sensor_paired_sensor_status response.""" return json.loads(load_fixture("sensor_paired_sensor_status_data.json", "guardian")) -@pytest.fixture(name="data_system_diagnostics", scope="session") +@pytest.fixture(name="data_system_diagnostics", scope="package") def data_system_diagnostics_fixture(): """Define data from a successful system_diagnostics response.""" return json.loads(load_fixture("system_diagnostics_data.json", "guardian")) -@pytest.fixture(name="data_system_onboard_sensor_status", scope="session") +@pytest.fixture(name="data_system_onboard_sensor_status", scope="package") def data_system_onboard_sensor_status_fixture(): """Define data from a successful system_onboard_sensor_status response.""" return json.loads( @@ -64,19 +64,19 @@ def data_system_onboard_sensor_status_fixture(): ) -@pytest.fixture(name="data_system_ping", scope="session") +@pytest.fixture(name="data_system_ping", scope="package") def data_system_ping_fixture(): """Define data from a successful system_ping response.""" return json.loads(load_fixture("system_ping_data.json", "guardian")) -@pytest.fixture(name="data_valve_status", scope="session") +@pytest.fixture(name="data_valve_status", scope="package") def data_valve_status_fixture(): """Define data from a successful valve_status response.""" return json.loads(load_fixture("valve_status_data.json", "guardian")) -@pytest.fixture(name="data_wifi_status", scope="session") +@pytest.fixture(name="data_wifi_status", scope="package") def data_wifi_status_fixture(): """Define data from a successful wifi_status response.""" return json.loads(load_fixture("wifi_status_data.json", "guardian")) diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index 2269d09b1eba96e6d4e090aa1a87ebe942191faa..ca6a8c7703900bf54b7f474e7ba301be733835bd 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -14,12 +14,21 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_guardian assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { - "title": "Mock Title", + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "guardian", + "title": REDACTED, "data": { + "uid": REDACTED, "ip_address": "192.168.1.100", "port": 7777, - "uid": REDACTED, }, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { "valve_controller": { diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index a601f98f1c5fd5508aaf51eaae4237eaed1416cf..c2dab178ad83bb7fc2c093146552ba06aafc3208 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -133,6 +133,19 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) @pytest.mark.parametrize( diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index 1f915e17e616a029ba7e2289c6a8ea86d639e12f..9eaaf5f97d99138d2eaccb5b8556f2e15957f19d 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -139,6 +139,19 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) async def test_diagnostics( diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 655cc4b23b5aaaaa2bb15f2debffb4f55627b175..94e989f3c777fdc1a89138998fab8e364aa0fb0b 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.components.hassio.discovery import HassioServiceInfo from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED @@ -14,8 +14,8 @@ from homeassistant.setup import async_setup_component from tests.common import MockModule, mock_entity_platform, mock_integration -@pytest.fixture -async def mock_mqtt(hass): +@pytest.fixture(name="mock_mqtt") +async def mock_mqtt_fixture(hass): """Mock the MQTT integration's config flow.""" mock_integration(hass, MockModule(MQTT_DOMAIN)) mock_entity_platform(hass, f"config_flow.{MQTT_DOMAIN}", None) @@ -78,7 +78,9 @@ async def test_hassio_discovery_startup(hass, aioclient_mock, hassio_client, moc "password": "mock-pass", "protocol": "3.1.1", "addon": "Mosquitto Test", - } + }, + name="Mosquitto Test", + slug="mosquitto", ) ) @@ -140,7 +142,9 @@ async def test_hassio_discovery_startup_done( "password": "mock-pass", "protocol": "3.1.1", "addon": "Mosquitto Test", - } + }, + name="Mosquitto Test", + slug="mosquitto", ) ) @@ -190,6 +194,8 @@ async def test_hassio_discovery_webhook(hass, aioclient_mock, hassio_client, moc "password": "mock-pass", "protocol": "3.1.1", "addon": "Mosquitto Test", - } + }, + name="Mosquitto Test", + slug="mosquitto", ) ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f0f94661d50805dffa0e719d50143598ba7ce100..371398e32c958a6e83ba1e38cfc73d0a84df448e 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -183,6 +183,19 @@ def mock_all(aioclient_mock, request, os_info): "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) async def test_setup_api_ping(hass, aioclient_mock): @@ -191,7 +204,7 @@ async def test_setup_api_ping(hass, aioclient_mock): result = await async_setup_component(hass, "hassio", {}) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -230,7 +243,7 @@ async def test_setup_api_push_api_data(hass, aioclient_mock): ) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["watchdog"] @@ -246,7 +259,7 @@ async def test_setup_api_push_api_data_server_host(hass, aioclient_mock): ) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -258,7 +271,7 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, hass_storag result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -325,7 +338,7 @@ async def test_setup_api_existing_hassio_user(hass, aioclient_mock, hass_storage result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -339,7 +352,7 @@ async def test_setup_core_push_timezone(hass, aioclient_mock): result = await async_setup_component(hass, "hassio", {"hassio": {}}) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -356,7 +369,7 @@ async def test_setup_hassio_no_additional_data(hass, aioclient_mock): result = await async_setup_component(hass, "hassio", {"hassio": {}}) assert result - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -426,14 +439,14 @@ async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 9 + assert aioclient_mock.call_count == 10 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 11 + assert aioclient_mock.call_count == 12 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -448,7 +461,7 @@ async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 13 + assert aioclient_mock.call_count == 14 assert aioclient_mock.mock_calls[-1][2] == { "homeassistant": True, "addons": ["test"], @@ -472,7 +485,7 @@ async def test_service_calls(hassio_env, hass, aioclient_mock, caplog): ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -491,12 +504,12 @@ async def test_service_calls_core(hassio_env, hass, aioclient_mock): await hass.services.async_call("homeassistant", "stop") await hass.async_block_till_done() - assert aioclient_mock.call_count == 5 + assert aioclient_mock.call_count == 6 await hass.services.async_call("homeassistant", "check_config") await hass.async_block_till_done() - assert aioclient_mock.call_count == 5 + assert aioclient_mock.call_count == 6 with patch( "homeassistant.config.async_check_ha_config_file", return_value=None @@ -505,7 +518,7 @@ async def test_service_calls_core(hassio_env, hass, aioclient_mock): await hass.async_block_till_done() assert mock_check_config.called - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 async def test_entry_load_and_unload(hass): @@ -758,7 +771,7 @@ async def test_setup_hardware_integration(hass, aioclient_mock, integration): assert result await hass.async_block_till_done() - assert aioclient_mock.call_count == 15 + assert aioclient_mock.call_count == 16 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py new file mode 100644 index 0000000000000000000000000000000000000000..f420e926b0906a4c8cce9d700c84420fb477d606 --- /dev/null +++ b/tests/components/hassio/test_repairs.py @@ -0,0 +1,464 @@ +"""Test repairs from supervisor issues.""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import ANY, patch + +import pytest + +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .test_init import MOCK_ENVIRON + +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(autouse=True) +async def setup_repairs(hass): + """Set up the repairs integration.""" + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + + +@pytest.fixture(autouse=True) +def mock_all(aioclient_mock: AiohttpClientMocker, request: pytest.FixtureRequest): + """Mock all setup requests.""" + aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) + aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/info", + json={ + "result": "ok", + "data": { + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": "1.2.3", + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/store", + json={ + "result": "ok", + "data": {"addons": [], "repositories": []}, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/host/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "data": { + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", + }, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={ + "result": "ok", + "data": { + "version_latest": "1.0.0", + "version": "1.0.0", + "update_available": False, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) + aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + + +@pytest.fixture(autouse=True) +async def fixture_supervisor_environ(): + """Mock os environ for supervisor.""" + with patch.dict(os.environ, MOCK_ENVIRON): + yield + + +def mock_resolution_info( + aioclient_mock: AiohttpClientMocker, + unsupported: list[str] | None = None, + unhealthy: list[str] | None = None, +): + """Mock resolution/info endpoint with unsupported/unhealthy reasons.""" + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": unsupported or [], + "unhealthy": unhealthy or [], + "suggestions": [], + "issues": [], + "checks": [ + {"enabled": True, "slug": "supervisor_trust"}, + {"enabled": True, "slug": "free_space"}, + ], + }, + }, + ) + + +def assert_repair_in_list(issues: list[dict[str, Any]], unhealthy: bool, reason: str): + """Assert repair for unhealthy/unsupported in list.""" + repair_type = "unhealthy" if unhealthy else "unsupported" + assert { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "hassio", + "ignored": False, + "is_fixable": False, + "issue_id": f"{repair_type}_system_{reason}", + "issue_domain": None, + "learn_more_url": f"https://www.home-assistant.io/more-info/{repair_type}/{reason}", + "severity": "critical" if unhealthy else "warning", + "translation_key": f"{repair_type}_{reason}", + "translation_placeholders": None, + } in issues + + +async def test_unhealthy_repairs( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Test repairs added for unhealthy systems.""" + mock_resolution_info(aioclient_mock, unhealthy=["docker", "setup"]) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="docker") + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="setup") + + +async def test_unsupported_repairs( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Test repairs added for unsupported systems.""" + mock_resolution_info(aioclient_mock, unsupported=["content_trust", "os"]) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert_repair_in_list( + msg["result"]["issues"], unhealthy=False, reason="content_trust" + ) + assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") + + +async def test_unhealthy_repairs_add_remove( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Test unhealthy repairs added and removed from dispatches.""" + mock_resolution_info(aioclient_mock) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "health_changed", + "data": { + "healthy": False, + "unhealthy_reasons": ["docker"], + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="docker") + + await client.send_json( + { + "id": 3, + "type": "supervisor/event", + "data": { + "event": "health_changed", + "data": {"healthy": True}, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 4, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issues": []} + + +async def test_unsupported_repairs_add_remove( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Test unsupported repairs added and removed from dispatches.""" + mock_resolution_info(aioclient_mock) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "supported_changed", + "data": { + "supported": False, + "unsupported_reasons": ["os"], + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") + + await client.send_json( + { + "id": 3, + "type": "supervisor/event", + "data": { + "event": "supported_changed", + "data": {"supported": True}, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 4, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issues": []} + + +async def test_reset_repairs_supervisor_restart( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Unsupported/unhealthy repairs reset on supervisor restart.""" + mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="docker") + assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") + + aioclient_mock.clear_requests() + mock_resolution_info(aioclient_mock) + await client.send_json( + { + "id": 2, + "type": "supervisor/event", + "data": { + "event": "supervisor_update", + "update_key": "supervisor", + "data": {}, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 3, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issues": []} + + +async def test_reasons_added_and_removed( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Test an unsupported/unhealthy reasons being added and removed at same time.""" + mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="docker") + assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") + + aioclient_mock.clear_requests() + mock_resolution_info( + aioclient_mock, unsupported=["content_trust"], unhealthy=["setup"] + ) + await client.send_json( + { + "id": 2, + "type": "supervisor/event", + "data": { + "event": "supervisor_update", + "update_key": "supervisor", + "data": {}, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 3, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="setup") + assert_repair_in_list( + msg["result"]["issues"], unhealthy=False, reason="content_trust" + ) + + +async def test_ignored_unsupported_skipped( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """Unsupported reasons which have an identical unhealthy reason are ignored.""" + mock_resolution_info( + aioclient_mock, unsupported=["privileged"], unhealthy=["privileged"] + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="privileged") + + +async def test_new_unsupported_unhealthy_reason( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client, +): + """New unsupported/unhealthy reasons result in a generic repair until next core update.""" + mock_resolution_info( + aioclient_mock, unsupported=["fake_unsupported"], unhealthy=["fake_unhealthy"] + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "hassio", + "ignored": False, + "is_fixable": False, + "issue_id": "unhealthy_system_fake_unhealthy", + "issue_domain": None, + "learn_more_url": "https://www.home-assistant.io/more-info/unhealthy/fake_unhealthy", + "severity": "critical", + "translation_key": "unhealthy", + "translation_placeholders": {"reason": "fake_unhealthy"}, + } in msg["result"]["issues"] + assert { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "hassio", + "ignored": False, + "is_fixable": False, + "issue_id": "unsupported_system_fake_unsupported", + "issue_domain": None, + "learn_more_url": "https://www.home-assistant.io/more-info/unsupported/fake_unsupported", + "severity": "warning", + "translation_key": "unsupported", + "translation_placeholders": {"reason": "fake_unsupported"}, + } in msg["result"]["issues"] diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 16cce09b800a767aa36116bbff70e262d3e7cb7d..e9f0bd631b06792c4383bedd286d8617ed3efec5 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -126,6 +126,19 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) @pytest.mark.parametrize( diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index aaa77cde129d9eb48dc5c79f2dcd5541ffc6e535..02d6b1dbf6bc4a1d322829d41e46765204200e0b 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -139,6 +139,19 @@ def mock_all(aioclient_mock, request): "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) @pytest.mark.parametrize( diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 5d11d13166e58bd195acd262efa4f721c8062b14..767f0abaf35ea0bb3519d2cccb1691567bffb5cf 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -61,6 +61,19 @@ def mock_all(aioclient_mock): aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) async def test_ws_subscription(hassio_env, hass: HomeAssistant, hass_ws_client): diff --git a/tests/components/here_travel_time/test_config_flow.py b/tests/components/here_travel_time/test_config_flow.py index b56f97a8053a1d552f5e7737e9724292863f6c29..120ffd828bc50a3593774aab22a2c322fe4d3125 100644 --- a/tests/components/here_travel_time/test_config_flow.py +++ b/tests/components/here_travel_time/test_config_flow.py @@ -31,6 +31,11 @@ from homeassistant.const import ( CONF_UNIT_SYSTEM_METRIC, ) from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from .const import ( API_KEY, @@ -227,10 +232,21 @@ async def test_step_destination_coordinates( @pytest.mark.usefixtures("valid_response") +@pytest.mark.parametrize( + "unit_system, expected_unit_option", + [ + (METRIC_SYSTEM, CONF_UNIT_SYSTEM_METRIC), + (US_CUSTOMARY_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL), + ], +) async def test_step_destination_entity( - hass: HomeAssistant, origin_step_result: data_entry_flow.FlowResult + hass: HomeAssistant, + origin_step_result: data_entry_flow.FlowResult, + unit_system: UnitSystem, + expected_unit_option: str, ) -> None: """Test the origin coordinates step.""" + hass.config.units = unit_system menu_result = await hass.config_entries.flow.async_configure( origin_step_result["flow_id"], {"next_step_id": "destination_entity"} ) @@ -250,6 +266,13 @@ async def test_step_destination_entity( CONF_DESTINATION_ENTITY_ID: "zone.home", CONF_MODE: TRAVEL_MODE_CAR, } + assert entry.options == { + CONF_UNIT_SYSTEM: expected_unit_option, + CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: TRAFFIC_MODE_ENABLED, + CONF_ARRIVAL_TIME: None, + CONF_DEPARTURE_TIME: None, + } async def test_form_invalid_auth(hass: HomeAssistant) -> None: diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 60b1f5fcced62437b3d42bf6dbb12eb7f301cd4a..5cc4802d253ded0bac41eded720629592f100ad6 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.here_travel_time.const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, - CONF_UNIT_SYSTEM, DOMAIN, ICON_BICYCLE, ICON_CAR, @@ -41,6 +40,7 @@ from homeassistant.const import ( CONF_API_KEY, CONF_MODE, CONF_NAME, + CONF_UNIT_SYSTEM, EVENT_HOMEASSISTANT_START, LENGTH_KILOMETERS, LENGTH_MILES, @@ -179,10 +179,6 @@ async def test_sensor( hass.states.get("sensor.test_distance").attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_distance_unit ) - assert hass.states.get("sensor.test_route").state == ( - "US-29 - K St NW; US-29 - Whitehurst Fwy; " - "I-495 N - Capital Beltway; MD-187 S - Old Georgetown Rd" - ) assert ( hass.states.get("sensor.test_duration_in_traffic").state == expected_duration_in_traffic diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 5441722c9d7a23d714d2a7c88caa49ee7ad2c7ce..981ff3bc08d4197a8ef21b5839dae4d52df04537 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -587,7 +587,7 @@ def record_states(hass): return zero, four, states -async def test_fetch_period_api(hass, hass_client, recorder_mock): +async def test_fetch_period_api(recorder_mock, hass, hass_client): """Test the fetch period view for history.""" await async_setup_component(hass, "history", {}) client = await hass_client() @@ -596,7 +596,7 @@ async def test_fetch_period_api(hass, hass_client, recorder_mock): async def test_fetch_period_api_with_use_include_order( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test the fetch period view for history with include order.""" await async_setup_component( @@ -607,7 +607,7 @@ async def test_fetch_period_api_with_use_include_order( assert response.status == HTTPStatus.OK -async def test_fetch_period_api_with_minimal_response(hass, recorder_mock, hass_client): +async def test_fetch_period_api_with_minimal_response(recorder_mock, hass, hass_client): """Test the fetch period view for history with minimal_response.""" now = dt_util.utcnow() await async_setup_component(hass, "history", {}) @@ -647,7 +647,7 @@ async def test_fetch_period_api_with_minimal_response(hass, recorder_mock, hass_ ).replace('"', "") -async def test_fetch_period_api_with_no_timestamp(hass, hass_client, recorder_mock): +async def test_fetch_period_api_with_no_timestamp(recorder_mock, hass, hass_client): """Test the fetch period view for history with no timestamp.""" await async_setup_component(hass, "history", {}) client = await hass_client() @@ -655,7 +655,7 @@ async def test_fetch_period_api_with_no_timestamp(hass, hass_client, recorder_mo assert response.status == HTTPStatus.OK -async def test_fetch_period_api_with_include_order(hass, hass_client, recorder_mock): +async def test_fetch_period_api_with_include_order(recorder_mock, hass, hass_client): """Test the fetch period view for history.""" await async_setup_component( hass, @@ -676,7 +676,7 @@ async def test_fetch_period_api_with_include_order(hass, hass_client, recorder_m async def test_fetch_period_api_with_entity_glob_include( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test the fetch period view for history.""" await async_setup_component( @@ -704,7 +704,7 @@ async def test_fetch_period_api_with_entity_glob_include( async def test_fetch_period_api_with_entity_glob_exclude( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test the fetch period view for history.""" await async_setup_component( @@ -744,7 +744,7 @@ async def test_fetch_period_api_with_entity_glob_exclude( async def test_fetch_period_api_with_entity_glob_include_and_exclude( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test the fetch period view for history.""" await async_setup_component( @@ -786,7 +786,7 @@ async def test_fetch_period_api_with_entity_glob_include_and_exclude( assert response_json[3][0]["entity_id"] == "switch.match" -async def test_entity_ids_limit_via_api(hass, hass_client, recorder_mock): +async def test_entity_ids_limit_via_api(recorder_mock, hass, hass_client): """Test limiting history to entity_ids.""" await async_setup_component( hass, @@ -811,7 +811,7 @@ async def test_entity_ids_limit_via_api(hass, hass_client, recorder_mock): async def test_entity_ids_limit_via_api_with_skip_initial_state( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test limiting history to entity_ids with skip_initial_state.""" await async_setup_component( @@ -844,7 +844,7 @@ async def test_entity_ids_limit_via_api_with_skip_initial_state( assert response_json[1][0]["entity_id"] == "light.cow" -async def test_statistics_during_period(hass, hass_ws_client, recorder_mock, caplog): +async def test_statistics_during_period(recorder_mock, hass, hass_ws_client, caplog): """Test history/statistics_during_period forwards to recorder.""" now = dt_util.utcnow() await async_setup_component(hass, "history", {}) @@ -889,7 +889,7 @@ async def test_statistics_during_period(hass, hass_ws_client, recorder_mock, cap ws_mock.assert_awaited_once() -async def test_list_statistic_ids(hass, hass_ws_client, recorder_mock, caplog): +async def test_list_statistic_ids(recorder_mock, hass, hass_ws_client, caplog): """Test history/list_statistic_ids forwards to recorder.""" await async_setup_component(hass, "history", {}) client = await hass_ws_client() @@ -914,7 +914,7 @@ async def test_list_statistic_ids(hass, hass_ws_client, recorder_mock, caplog): ws_mock.assert_called_once() -async def test_history_during_period(hass, hass_ws_client, recorder_mock): +async def test_history_during_period(recorder_mock, hass, hass_ws_client): """Test history_during_period.""" now = dt_util.utcnow() @@ -1047,7 +1047,7 @@ async def test_history_during_period(hass, hass_ws_client, recorder_mock): async def test_history_during_period_impossible_conditions( - hass, hass_ws_client, recorder_mock + recorder_mock, hass, hass_ws_client ): """Test history_during_period returns when condition cannot be true.""" await async_setup_component(hass, "history", {}) @@ -1109,7 +1109,7 @@ async def test_history_during_period_impossible_conditions( "time_zone", ["UTC", "Europe/Berlin", "America/Chicago", "US/Hawaii"] ) async def test_history_during_period_significant_domain( - time_zone, hass, hass_ws_client, recorder_mock + time_zone, recorder_mock, hass, hass_ws_client ): """Test history_during_period with climate domain.""" hass.config.set_time_zone(time_zone) @@ -1274,7 +1274,7 @@ async def test_history_during_period_significant_domain( async def test_history_during_period_bad_start_time( - hass, hass_ws_client, recorder_mock + recorder_mock, hass, hass_ws_client ): """Test history_during_period bad state time.""" await async_setup_component( @@ -1296,7 +1296,7 @@ async def test_history_during_period_bad_start_time( assert response["error"]["code"] == "invalid_start_time" -async def test_history_during_period_bad_end_time(hass, hass_ws_client, recorder_mock): +async def test_history_during_period_bad_end_time(recorder_mock, hass, hass_ws_client): """Test history_during_period bad end time.""" now = dt_util.utcnow() @@ -1321,7 +1321,7 @@ async def test_history_during_period_bad_end_time(hass, hass_ws_client, recorder async def test_history_during_period_with_use_include_order( - hass, hass_ws_client, recorder_mock + recorder_mock, hass, hass_ws_client ): """Test history_during_period.""" now = dt_util.utcnow() diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 5de74f71d1ea7269c6a7b7b161f03e2a58032e2f..6bae61b5fd837623eef5fec2f19bc5d25d70c2f5 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -136,7 +136,7 @@ class TestHistoryStatsSensor(unittest.TestCase): self.hass.start() -async def test_invalid_date_for_start(hass, recorder_mock): +async def test_invalid_date_for_start(recorder_mock, hass): """Verify with an invalid date for start.""" await async_setup_component( hass, @@ -161,7 +161,7 @@ async def test_invalid_date_for_start(hass, recorder_mock): assert hass.states.get("sensor.test") is None -async def test_invalid_date_for_end(hass, recorder_mock): +async def test_invalid_date_for_end(recorder_mock, hass): """Verify with an invalid date for end.""" await async_setup_component( hass, @@ -186,7 +186,7 @@ async def test_invalid_date_for_end(hass, recorder_mock): assert hass.states.get("sensor.test") is None -async def test_invalid_entity_in_template(hass, recorder_mock): +async def test_invalid_entity_in_template(recorder_mock, hass): """Verify with an invalid entity in the template.""" await async_setup_component( hass, @@ -211,7 +211,7 @@ async def test_invalid_entity_in_template(hass, recorder_mock): assert hass.states.get("sensor.test") is None -async def test_invalid_entity_returning_none_in_template(hass, recorder_mock): +async def test_invalid_entity_returning_none_in_template(recorder_mock, hass): """Verify with an invalid entity returning none in the template.""" await async_setup_component( hass, @@ -236,7 +236,7 @@ async def test_invalid_entity_returning_none_in_template(hass, recorder_mock): assert hass.states.get("sensor.test") is None -async def test_reload(hass, recorder_mock): +async def test_reload(recorder_mock, hass): """Verify we can reload history_stats sensors.""" hass.state = ha.CoreState.not_running hass.states.async_set("binary_sensor.test_id", "on") @@ -279,7 +279,7 @@ async def test_reload(hass, recorder_mock): assert hass.states.get("sensor.second_test") -async def test_measure_multiple(hass, recorder_mock): +async def test_measure_multiple(recorder_mock, hass): """Test the history statistics sensor measure for multiple .""" start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) @@ -361,7 +361,7 @@ async def test_measure_multiple(hass, recorder_mock): assert hass.states.get("sensor.sensor4").state == "50.0" -async def test_measure(hass, recorder_mock): +async def test_measure(recorder_mock, hass): """Test the history statistics sensor measure.""" start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) @@ -440,7 +440,7 @@ async def test_measure(hass, recorder_mock): assert hass.states.get("sensor.sensor4").state == "83.3" -async def test_async_on_entire_period(hass, recorder_mock): +async def test_async_on_entire_period(recorder_mock, hass): """Test the history statistics sensor measuring as on the entire period.""" start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) @@ -520,7 +520,7 @@ async def test_async_on_entire_period(hass, recorder_mock): assert hass.states.get("sensor.on_sensor4").state == "100.0" -async def test_async_off_entire_period(hass, recorder_mock): +async def test_async_off_entire_period(recorder_mock, hass): """Test the history statistics sensor measuring as off the entire period.""" start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) @@ -602,8 +602,8 @@ async def test_async_off_entire_period(hass, recorder_mock): async def test_async_start_from_history_and_switch_to_watching_state_changes_single( - hass, recorder_mock, + hass, ): """Test we startup from history and switch to watching state changes.""" hass.config.set_time_zone("UTC") @@ -702,8 +702,8 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin async def test_async_start_from_history_and_switch_to_watching_state_changes_single_expanding_window( - hass, recorder_mock, + hass, ): """Test we startup from history and switch to watching state changes with an expanding end time.""" hass.config.set_time_zone("UTC") @@ -801,8 +801,8 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin async def test_async_start_from_history_and_switch_to_watching_state_changes_multiple( - hass, recorder_mock, + hass, ): """Test we startup from history and switch to watching state changes.""" hass.config.set_time_zone("UTC") @@ -938,7 +938,7 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_mul assert hass.states.get("sensor.sensor4").state == "87.5" -async def test_does_not_work_into_the_future(hass, recorder_mock): +async def test_does_not_work_into_the_future(recorder_mock, hass): """Test history cannot tell the future. Verifies we do not regress https://github.com/home-assistant/core/pull/20589 @@ -1078,7 +1078,7 @@ async def test_does_not_work_into_the_future(hass, recorder_mock): assert hass.states.get("sensor.sensor1").state == "0.0" -async def test_reload_before_start_event(hass, recorder_mock): +async def test_reload_before_start_event(recorder_mock, hass): """Verify we can reload history_stats sensors before the start event.""" hass.state = ha.CoreState.not_running hass.states.async_set("binary_sensor.test_id", "on") @@ -1119,7 +1119,7 @@ async def test_reload_before_start_event(hass, recorder_mock): assert hass.states.get("sensor.second_test") -async def test_measure_sliding_window(hass, recorder_mock): +async def test_measure_sliding_window(recorder_mock, hass): """Test the history statistics sensor with a moving end and a moving start.""" start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) @@ -1212,7 +1212,7 @@ async def test_measure_sliding_window(hass, recorder_mock): assert hass.states.get("sensor.sensor4").state == "41.7" -async def test_measure_from_end_going_backwards(hass, recorder_mock): +async def test_measure_from_end_going_backwards(recorder_mock, hass): """Test the history statistics sensor with a moving end and a duration to find the start.""" start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) @@ -1304,7 +1304,7 @@ async def test_measure_from_end_going_backwards(hass, recorder_mock): assert hass.states.get("sensor.sensor4").state == "83.3" -async def test_measure_cet(hass, recorder_mock): +async def test_measure_cet(recorder_mock, hass): """Test the history statistics sensor measure with a non-UTC timezone.""" hass.config.set_time_zone("Europe/Berlin") start_time = dt_util.utcnow() - timedelta(minutes=60) @@ -1385,10 +1385,12 @@ async def test_measure_cet(hass, recorder_mock): @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii"]) -async def test_end_time_with_microseconds_zeroed(time_zone, hass, recorder_mock): +async def test_end_time_with_microseconds_zeroed(time_zone, recorder_mock, hass): """Test the history statistics sensor that has the end time microseconds zeroed out.""" hass.config.set_time_zone(time_zone) - start_of_today = dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_of_today = dt_util.now().replace( + day=9, month=7, year=1986, hour=0, minute=0, second=0, microsecond=0 + ) start_time = start_of_today + timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) t1 = t0 + timedelta(minutes=10) @@ -1498,7 +1500,7 @@ async def test_end_time_with_microseconds_zeroed(time_zone, hass, recorder_mock) assert hass.states.get("sensor.heatpump_compressor_today").state == "16.0" -async def test_device_classes(hass, recorder_mock): +async def test_device_classes(recorder_mock, hass): """Test the device classes.""" await async_setup_component( hass, diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index 5e2acbcd9dbd35728fc9cff2e9f3eaf56a8d61da..7b79e0f9b6b6f205a20628ab5e4cd578d45f133b 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -3,17 +3,50 @@ from contextlib import suppress import os from unittest.mock import patch -from pyhap.accessory_driver import AccessoryDriver import pytest from homeassistant.components.device_tracker.legacy import YAML_DEVICES -from homeassistant.components.homekit.const import EVENT_HOMEKIT_CHANGED +from homeassistant.components.homekit.accessories import HomeDriver, HomeIIDManager +from homeassistant.components.homekit.const import BRIDGE_NAME, EVENT_HOMEKIT_CHANGED +from homeassistant.components.homekit.iidmanager import AccessoryIIDStorage from tests.common import async_capture_events, mock_device_registry, mock_registry @pytest.fixture -def hk_driver(loop): +def iid_storage(hass): + """Mock the iid storage.""" + with patch.object(AccessoryIIDStorage, "_async_schedule_save"): + yield AccessoryIIDStorage(hass, "") + + +@pytest.fixture() +def run_driver(hass, loop, iid_storage): + """Return a custom AccessoryDriver instance for HomeKit accessory init. + + This mock does not mock async_stop, so the driver will not be stopped + """ + with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( + "pyhap.accessory_driver.AccessoryEncoder" + ), patch("pyhap.accessory_driver.HAPServer"), patch( + "pyhap.accessory_driver.AccessoryDriver.publish" + ), patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ): + yield HomeDriver( + hass, + pincode=b"123-45-678", + entry_id="", + entry_title="mock entry", + bridge_name=BRIDGE_NAME, + iid_manager=HomeIIDManager(iid_storage), + address="127.0.0.1", + loop=loop, + ) + + +@pytest.fixture +def hk_driver(hass, loop, iid_storage): """Return a custom AccessoryDriver instance for HomeKit accessory init.""" with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( "pyhap.accessory_driver.AccessoryEncoder" @@ -24,11 +57,20 @@ def hk_driver(loop): ), patch( "pyhap.accessory_driver.AccessoryDriver.persist" ): - yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop) + yield HomeDriver( + hass, + pincode=b"123-45-678", + entry_id="", + entry_title="mock entry", + bridge_name=BRIDGE_NAME, + iid_manager=HomeIIDManager(iid_storage), + address="127.0.0.1", + loop=loop, + ) @pytest.fixture -def mock_hap(loop, mock_zeroconf): +def mock_hap(hass, loop, iid_storage, mock_zeroconf): """Return a custom AccessoryDriver instance for HomeKit accessory init.""" with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( "pyhap.accessory_driver.AccessoryEncoder" @@ -43,7 +85,16 @@ def mock_hap(loop, mock_zeroconf): ), patch( "pyhap.accessory_driver.AccessoryDriver.persist" ): - yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop) + yield HomeDriver( + hass, + pincode=b"123-45-678", + entry_id="", + entry_title="mock entry", + bridge_name=BRIDGE_NAME, + iid_manager=HomeIIDManager(iid_storage), + address="127.0.0.1", + loop=loop, + ) @pytest.fixture diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 6d7de6eb696a796a436882e874a44d494845ba77..2a0f3f2f7187971dfb5ec3985e1a1859c991667b 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -10,6 +10,7 @@ from homeassistant.components.homekit.accessories import ( HomeAccessory, HomeBridge, HomeDriver, + HomeIIDManager, ) from homeassistant.components.homekit.const import ( ATTR_DISPLAY_NAME, @@ -107,7 +108,7 @@ async def test_home_accessory(hass, hk_driver): hk_driver, "Home Accessory that exceeds the maximum maximum maximum maximum maximum maximum length", entity_id2, - 3, + 4, { ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length", ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length", @@ -140,7 +141,7 @@ async def test_home_accessory(hass, hk_driver): hk_driver, "Home Accessory that exceeds the maximum maximum maximum maximum maximum maximum length", entity_id2, - 3, + 5, { ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length", ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length", @@ -191,7 +192,7 @@ async def test_home_accessory(hass, hk_driver): entity_id = "test_model.demo" hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = HomeAccessory(hass, hk_driver, "test_name", entity_id, 2, None) + acc = HomeAccessory(hass, hk_driver, "test_name", entity_id, 6, None) serv = acc.services[0] # SERV_ACCESSORY_INFO assert serv.get_characteristic(CHAR_MODEL).value == "Test Model" @@ -317,7 +318,7 @@ async def test_battery_service(hass, hk_driver, caplog): with patch( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ): - acc = HomeAccessory(hass, hk_driver, "Battery Service", entity_id, 2, None) + acc = HomeAccessory(hass, hk_driver, "Battery Service", entity_id, 3, None) assert acc._char_battery.value == 0 assert acc._char_low_battery.value == 0 assert acc._char_charging.value == 2 @@ -405,7 +406,7 @@ async def test_linked_battery_sensor(hass, hk_driver, caplog): hk_driver, "Battery Service", entity_id, - 2, + 3, {CONF_LINKED_BATTERY_SENSOR: linked_battery, CONF_LOW_BATTERY_THRESHOLD: 50}, ) with patch( @@ -700,16 +701,17 @@ def test_home_bridge(hk_driver): assert serv.get_characteristic(CHAR_MODEL).value == BRIDGE_MODEL assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == BRIDGE_SERIAL_NUMBER + +def test_home_bridge_setup_message(hk_driver): + """Test HomeBridge setup message.""" bridge = HomeBridge("hass", hk_driver, "test_name") assert bridge.display_name == "test_name" assert len(bridge.services) == 2 - serv = bridge.services[0] # SERV_ACCESSORY_INFO - # setup_message bridge.setup_message() -def test_home_driver(): +def test_home_driver(iid_storage): """Test HomeDriver class.""" ip_address = "127.0.0.1" port = 51826 @@ -722,6 +724,7 @@ def test_home_driver(): "entry_id", "name", "title", + iid_manager=HomeIIDManager(iid_storage), address=ip_address, port=port, persist_file=path, @@ -749,3 +752,22 @@ def test_home_driver(): mock_unpair.assert_called_with("client_uuid") mock_show_msg.assert_called_with("hass", "entry_id", "title (any)", pin, "X-HM://0") + + +async def test_iid_collision_raises(hass, hk_driver): + """Test iid collision raises. + + If we try to allocate the same IID to the an accessory twice, we should + raise an exception. + """ + + entity_id = "light.accessory" + entity_id2 = "light.accessory2" + + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id2, STATE_OFF) + + HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, {}) + + with pytest.raises(RuntimeError): + HomeAccessory(hass, hk_driver, "Home Accessory", entity_id2, 2, {}) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 1b2a0b4e211c4d1d0f9266163cb85f4762636093..144a97853c58aa7f568c2a8f8069cbb2e9fe78fe 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -387,8 +387,9 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip): } +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) async def test_options_flow_devices( - mock_hap, + port_mock, hass, demo_cleanup, device_reg, @@ -473,9 +474,13 @@ async def test_options_flow_devices( }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) + +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) async def test_options_flow_devices_preserved_when_advanced_off( - mock_hap, hass, mock_get_source_ip, mock_async_zeroconf + port_mock, hass, mock_get_source_ip, mock_async_zeroconf ): """Test devices are preserved if they were added in advanced mode but it was turned off.""" config_entry = MockConfigEntry( @@ -542,6 +547,8 @@ async def test_options_flow_devices_preserved_when_advanced_off( "include_entities": [], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_include_mode_with_non_existant_entity( @@ -600,6 +607,8 @@ async def test_options_flow_include_mode_with_non_existant_entity( "include_entities": ["climate.new", "climate.front_gate"], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_exclude_mode_with_non_existant_entity( @@ -659,6 +668,8 @@ async def test_options_flow_exclude_mode_with_non_existant_entity( "include_entities": [], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_include_mode_basic(hass, mock_get_source_ip): @@ -704,6 +715,7 @@ async def test_options_flow_include_mode_basic(hass, mock_get_source_ip): "include_entities": ["climate.new"], }, } + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip): @@ -809,6 +821,8 @@ async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip): }, "entity_config": {"camera.native_h264": {"video_codec": "copy"}}, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): @@ -941,6 +955,8 @@ async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): }, "mode": "bridge", } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_with_camera_audio(hass, mock_get_source_ip): @@ -1073,6 +1089,8 @@ async def test_options_flow_with_camera_audio(hass, mock_get_source_ip): }, "mode": "bridge", } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_blocked_when_from_yaml(hass, mock_get_source_ip): @@ -1112,6 +1130,7 @@ async def test_options_flow_blocked_when_from_yaml(hass, mock_get_source_ip): user_input={}, ) assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + await hass.config_entries.async_unload(config_entry.entry_id) @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) @@ -1211,6 +1230,8 @@ async def test_options_flow_include_mode_basic_accessory( "include_entities": ["media_player.tv"], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_source_ip): @@ -1317,6 +1338,8 @@ async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_sou }, } assert len(mock_setup_entry.mock_calls) == 1 + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) def _get_schema_default(schema, key_name): @@ -1423,6 +1446,8 @@ async def test_options_flow_exclude_mode_skips_category_entities( "include_entities": [], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) @@ -1501,6 +1526,8 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( "include_entities": [], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) @@ -1583,3 +1610,5 @@ async def test_options_flow_include_mode_allows_hidden_entities( ], }, } + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/homekit/test_diagnostics.py b/tests/components/homekit/test_diagnostics.py index e3a85b85972f973fea393b290941b7367e4db8e8..1f6f7c584f3ee6550a5695c55e4784dd47816d98 100644 --- a/tests/components/homekit/test_diagnostics.py +++ b/tests/components/homekit/test_diagnostics.py @@ -1,7 +1,11 @@ """Test homekit diagnostics.""" from unittest.mock import ANY, patch -from homeassistant.components.homekit.const import DOMAIN +from homeassistant.components.homekit.const import ( + CONF_HOMEKIT_MODE, + DOMAIN, + HOMEKIT_MODE_ACCESSORY, +) from homeassistant.const import CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STARTED from .util import async_init_integration @@ -28,7 +32,7 @@ async def test_config_entry_not_running( async def test_config_entry_running(hass, hass_client, hk_driver, mock_async_zeroconf): - """Test generating diagnostics for a config entry.""" + """Test generating diagnostics for a bridge config entry.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} ) @@ -38,6 +42,7 @@ async def test_config_entry_running(hass, hass_client, hk_driver, mock_async_zer await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diag == { + "bridge": {}, "accessories": [ { "aid": 1, @@ -117,3 +122,145 @@ async def test_config_entry_running(hass, hass_client, hk_driver, mock_async_zer ), patch("homeassistant.components.homekit.async_port_is_available"): assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_config_entry_accessory( + hass, hass_client, hk_driver, mock_async_zeroconf +): + """Test generating diagnostics for an accessory config entry.""" + hass.states.async_set("light.demo", "on") + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_NAME: "mock_name", + CONF_PORT: 12345, + CONF_HOMEKIT_MODE: HOMEKIT_MODE_ACCESSORY, + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": [], + "include_entities": ["light.demo"], + }, + }, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) + assert diag == { + "accessories": [ + { + "aid": 1, + "services": [ + { + "characteristics": [ + {"format": "bool", "iid": 2, "perms": ["pw"], "type": "14"}, + { + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "20", + "value": "Home Assistant " "Light", + }, + { + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "21", + "value": "Light", + }, + { + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "23", + "value": "demo", + }, + { + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "30", + "value": "light.demo", + }, + { + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "52", + "value": ANY, + }, + ], + "iid": 1, + "type": "3E", + }, + { + "characteristics": [ + { + "format": "string", + "iid": 9, + "perms": ["pr", "ev"], + "type": "37", + "value": "01.01.00", + } + ], + "iid": 8, + "type": "A2", + }, + { + "characteristics": [ + { + "format": "bool", + "iid": 11, + "perms": ["pr", "pw", "ev"], + "type": "25", + "value": True, + } + ], + "iid": 10, + "type": "43", + }, + ], + } + ], + "accessory": { + "aid": 1, + "category": 5, + "config": {}, + "entity_id": "light.demo", + "entity_state": { + "attributes": {}, + "context": {"id": ANY, "parent_id": None, "user_id": None}, + "entity_id": "light.demo", + "last_changed": ANY, + "last_updated": ANY, + "state": "on", + }, + "name": "demo", + }, + "client_properties": {}, + "config-entry": { + "data": {"name": "mock_name", "port": 12345}, + "options": { + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": [], + "include_entities": ["light.demo"], + }, + "mode": "accessory", + }, + "title": "Mock Title", + "version": 1, + }, + "config_version": 2, + "pairing_id": ANY, + "status": 1, + } + with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( + "homeassistant.components.homekit.HomeKit.async_stop" + ), patch("homeassistant.components.homekit.async_port_is_available"): + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index dbb63ba690a60d38d4d65a305bea6c05b9d82c0d..21dc94a4b54371144cbf342fea03ad22d72b2ac1 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -import os from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch from pyhap.accessory import Accessory @@ -60,7 +59,6 @@ from homeassistant.helpers.entityfilter import ( convert_filter, ) from homeassistant.setup import async_setup_component -from homeassistant.util import json as json_util from .util import PATH_HOMEKIT, async_init_entry, async_init_integration @@ -122,6 +120,7 @@ def _mock_homekit(hass, entry, homekit_mode, entity_filter=None, devices=None): def _mock_homekit_bridge(hass, entry): homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) homekit.driver = MagicMock() + homekit.iid_storage = MagicMock() return homekit @@ -177,6 +176,49 @@ async def test_setup_min(hass, mock_async_zeroconf): assert mock_homekit().async_start.called is True +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +async def test_removing_entry(port_mock, hass, mock_async_zeroconf): + """Test removing a config entry.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT}, + options={}, + ) + entry.add_to_hass(hass) + + with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit, patch( + "homeassistant.components.network.async_get_source_ip", return_value="1.2.3.4" + ): + mock_homekit.return_value = homekit = Mock() + type(homekit).async_start = AsyncMock() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_homekit.assert_any_call( + hass, + BRIDGE_NAME, + DEFAULT_PORT, + "1.2.3.4", + ANY, + ANY, + {}, + HOMEKIT_MODE_BRIDGE, + None, + entry.entry_id, + entry.title, + devices=[], + ) + + # Test auto start enabled + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert mock_homekit().async_start.called is True + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf): """Test setup of bridge and driver.""" entry = MockConfigEntry( @@ -203,6 +245,7 @@ async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf): zeroconf_mock = MagicMock() uuid = await instance_id.async_get(hass) with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: + homekit.iid_storage = MagicMock() await hass.async_add_executor_job(homekit.setup, zeroconf_mock, uuid) path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) @@ -219,6 +262,7 @@ async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf): async_zeroconf_instance=zeroconf_mock, zeroconf_server=f"{uuid}-hap.local.", loader=ANY, + iid_manager=ANY, ) assert homekit.driver.safe_mode is False @@ -247,6 +291,7 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_async_zeroconf): path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) uuid = await instance_id.async_get(hass) with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: + homekit.iid_storage = MagicMock() await hass.async_add_executor_job(homekit.setup, mock_async_zeroconf, uuid) mock_driver.assert_called_with( hass, @@ -261,6 +306,7 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_async_zeroconf): async_zeroconf_instance=mock_async_zeroconf, zeroconf_server=f"{uuid}-hap.local.", loader=ANY, + iid_manager=ANY, ) @@ -289,6 +335,7 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_async_zeroconf): path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) uuid = await instance_id.async_get(hass) with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: + homekit.iid_storage = MagicMock() await hass.async_add_executor_job(homekit.setup, async_zeroconf_instance, uuid) mock_driver.assert_called_with( hass, @@ -303,10 +350,11 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_async_zeroconf): async_zeroconf_instance=async_zeroconf_instance, zeroconf_server=f"{uuid}-hap.local.", loader=ANY, + iid_manager=ANY, ) -async def test_homekit_add_accessory(hass, mock_async_zeroconf): +async def test_homekit_add_accessory(hass, mock_async_zeroconf, mock_hap): """Add accessory if config exists and get_acc returns an accessory.""" entry = MockConfigEntry( @@ -340,10 +388,12 @@ async def test_homekit_add_accessory(hass, mock_async_zeroconf): mock_get_acc.assert_called_with(hass, ANY, ANY, 1467253281, {}) assert homekit.bridge.add_accessory.called + await homekit.async_stop() + @pytest.mark.parametrize("acc_category", [CATEGORY_TELEVISION, CATEGORY_CAMERA]) async def test_homekit_warn_add_accessory_bridge( - hass, acc_category, mock_async_zeroconf, caplog + hass, acc_category, mock_async_zeroconf, mock_hap, caplog ): """Test we warn when adding cameras or tvs to a bridge.""" @@ -367,6 +417,7 @@ async def test_homekit_warn_add_accessory_bridge( homekit.add_bridge_accessory(state) mock_get_acc.assert_called_with(hass, ANY, ANY, 1508819236, {}) assert not homekit.bridge.add_accessory.called + await homekit.async_stop() assert "accessory mode" in caplog.text @@ -385,7 +436,6 @@ async def test_homekit_remove_accessory(hass, mock_async_zeroconf): acc = await homekit.async_remove_bridge_accessory(6) assert acc is acc_mock - assert acc_mock.stop.called assert len(homekit.bridge.accessories) == 0 @@ -722,7 +772,7 @@ async def test_homekit_stop(hass): assert homekit.driver.async_stop.called is True -async def test_homekit_reset_accessories(hass, mock_async_zeroconf): +async def test_homekit_reset_accessories(hass, mock_async_zeroconf, mock_hap): """Test resetting HomeKit accessories.""" entry = MockConfigEntry( @@ -736,20 +786,15 @@ async def test_homekit_reset_accessories(hass, mock_async_zeroconf): "pyhap.accessory.Bridge.add_accessory" ) as mock_add_accessory, patch( "pyhap.accessory_driver.AccessoryDriver.config_changed" - ) as hk_driver_config_changed, patch( + ), patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch( f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" - ) as mock_run, patch.object( + ), patch.object( homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 ): await async_init_entry(hass, entry) - acc_mock = MagicMock() - acc_mock.entity_id = entity_id - acc_mock.stop = AsyncMock() - aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) - homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING homekit.driver.aio_stop_event = MagicMock() @@ -761,10 +806,9 @@ async def test_homekit_reset_accessories(hass, mock_async_zeroconf): ) await hass.async_block_till_done() - assert hk_driver_config_changed.call_count == 2 assert mock_add_accessory.called - assert mock_run.called homekit.status = STATUS_READY + await homekit.async_stop() async def test_homekit_unpair(hass, device_reg, mock_async_zeroconf): @@ -1028,7 +1072,7 @@ async def test_homekit_reset_accessories_not_bridged(hass, mock_async_zeroconf): homekit.status = STATUS_STOPPED -async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf): +async def test_homekit_reset_single_accessory(hass, mock_hap, mock_async_zeroconf): """Test resetting HomeKit single accessory.""" entry = MockConfigEntry( @@ -1046,13 +1090,7 @@ async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf): f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" ) as mock_run: await async_init_entry(hass, entry) - homekit.status = STATUS_RUNNING - acc_mock = MagicMock() - acc_mock.entity_id = entity_id - acc_mock.stop = AsyncMock() - - homekit.driver.accessory = acc_mock homekit.driver.aio_stop_event = MagicMock() await hass.services.async_call( @@ -1065,6 +1103,7 @@ async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf): assert mock_run.called assert hk_driver_config_changed.call_count == 1 homekit.status = STATUS_READY + await homekit.async_stop() async def test_homekit_reset_single_accessory_unsupported(hass, mock_async_zeroconf): @@ -1494,12 +1533,6 @@ async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_async_zeroconf await hass.async_block_till_done() -def _write_data(path: str, data: dict) -> None: - """Write the data.""" - os.makedirs(os.path.dirname(path), exist_ok=True) - json_util.save_json(path, data) - - async def test_homekit_ignored_missing_devices( hass, hk_driver, device_reg, entity_reg, mock_async_zeroconf ): diff --git a/tests/components/homekit/test_iidmanager.py b/tests/components/homekit/test_iidmanager.py new file mode 100644 index 0000000000000000000000000000000000000000..a791c30a3416e061bc07a2e699b4bb08f96b1738 --- /dev/null +++ b/tests/components/homekit/test_iidmanager.py @@ -0,0 +1,101 @@ +"""Tests for the HomeKit IID manager.""" + + +from uuid import UUID + +from homeassistant.components.homekit.const import DOMAIN +from homeassistant.components.homekit.iidmanager import ( + AccessoryIIDStorage, + get_iid_storage_filename_for_entry_id, +) +from homeassistant.util.uuid import random_uuid_hex + +from tests.common import MockConfigEntry + + +async def test_iid_generation_and_restore(hass, iid_storage, hass_storage): + """Test generating iids and restoring them from storage.""" + entry = MockConfigEntry(domain=DOMAIN) + + iid_storage = AccessoryIIDStorage(hass, entry.entry_id) + await iid_storage.async_initialize() + + random_service_uuid = UUID(random_uuid_hex()) + random_characteristic_uuid = UUID(random_uuid_hex()) + + iid1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, None + ) + iid2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, None + ) + assert iid1 == iid2 + + service_only_iid1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, None, None + ) + service_only_iid2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, None, None + ) + assert service_only_iid1 == service_only_iid2 + assert service_only_iid1 != iid1 + + service_only_iid_with_unique_id1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, "any", None, None + ) + service_only_iid_with_unique_id2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, "any", None, None + ) + assert service_only_iid_with_unique_id1 == service_only_iid_with_unique_id2 + assert service_only_iid_with_unique_id1 != service_only_iid1 + + unique_char_iid1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, "any" + ) + unique_char_iid2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, "any" + ) + assert unique_char_iid1 == unique_char_iid2 + assert unique_char_iid1 != iid1 + + unique_service_unique_char_iid1 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, "any", random_characteristic_uuid, "any" + ) + unique_service_unique_char_iid2 = iid_storage.get_or_allocate_iid( + 1, random_service_uuid, "any", random_characteristic_uuid, "any" + ) + assert unique_service_unique_char_iid1 == unique_service_unique_char_iid2 + assert unique_service_unique_char_iid1 != iid1 + + unique_service_unique_char_new_aid_iid1 = iid_storage.get_or_allocate_iid( + 2, random_service_uuid, "any", random_characteristic_uuid, "any" + ) + unique_service_unique_char_new_aid_iid2 = iid_storage.get_or_allocate_iid( + 2, random_service_uuid, "any", random_characteristic_uuid, "any" + ) + assert ( + unique_service_unique_char_new_aid_iid1 + == unique_service_unique_char_new_aid_iid2 + ) + assert unique_service_unique_char_new_aid_iid1 != iid1 + assert unique_service_unique_char_new_aid_iid1 != unique_service_unique_char_iid1 + + await iid_storage.async_save() + + iid_storage2 = AccessoryIIDStorage(hass, entry.entry_id) + await iid_storage2.async_initialize() + iid3 = iid_storage2.get_or_allocate_iid( + 1, random_service_uuid, None, random_characteristic_uuid, None + ) + assert iid3 == iid1 + + +async def test_iid_storage_filename(hass, iid_storage, hass_storage): + """Test iid storage uses the expected filename.""" + entry = MockConfigEntry(domain=DOMAIN) + + iid_storage = AccessoryIIDStorage(hass, entry.entry_id) + await iid_storage.async_initialize() + assert iid_storage.store.path.endswith( + get_iid_storage_filename_for_entry_id(entry.entry_id) + ) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index f6855ca3cbbbc9f37d4cab966b3d4878c36ef453..80a4f3c4e88982e2b111b7b9224d26c9a7d8d5a0 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -4,7 +4,6 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from uuid import UUID -from pyhap.accessory_driver import AccessoryDriver import pytest from homeassistant.components import camera, ffmpeg @@ -78,21 +77,6 @@ async def _async_stop_stream(hass, acc, session_info): await hass.async_block_till_done() -@pytest.fixture() -def run_driver(hass): - """Return a custom AccessoryDriver instance for HomeKit accessory init.""" - with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( - "pyhap.accessory_driver.AccessoryEncoder" - ), patch("pyhap.accessory_driver.HAPServer"), patch( - "pyhap.accessory_driver.AccessoryDriver.publish" - ), patch( - "pyhap.accessory_driver.AccessoryDriver.persist" - ): - yield AccessoryDriver( - pincode=b"123-45-678", address="127.0.0.1", loop=hass.loop - ) - - def _mock_reader(): """Mock ffmpeg reader.""" diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index bc512a4b162b84f9e1ffa51ec5a3a97ddfbb0249..5d261886248c49eeb0106afb258b11cef5127527 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -573,7 +573,7 @@ async def test_windowcovering_basic_restore(hass, hk_driver, events): assert acc.char_target_position is not None assert acc.char_position_state is not None - acc = WindowCoveringBasic(hass, hk_driver, "Cover", "cover.all_info_set", 2, None) + acc = WindowCoveringBasic(hass, hk_driver, "Cover", "cover.all_info_set", 3, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None @@ -611,7 +611,7 @@ async def test_windowcovering_restore(hass, hk_driver, events): assert acc.char_target_position is not None assert acc.char_position_state is not None - acc = WindowCovering(hass, hk_driver, "Cover", "cover.all_info_set", 2, None) + acc = WindowCovering(hass, hk_driver, "Cover", "cover.all_info_set", 3, None) assert acc.category == 14 assert acc.char_current_position is not None assert acc.char_target_position is not None diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index fbcf6a004215c889775135ad8ed7980a1345362c..9b5f8286d8b51d67c3ec3fe46f1ca181c8083ef0 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -561,7 +561,7 @@ async def test_fan_restore(hass, hk_driver, events): assert acc.char_speed is None assert acc.char_swing is None - acc = Fan(hass, hk_driver, "Fan", "fan.all_info_set", 2, None) + acc = Fan(hass, hk_driver, "Fan", "fan.all_info_set", 3, None) assert acc.category == 3 assert acc.char_active is not None assert acc.char_direction is not None diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 64e45aa937db7f79c5c0c911341bc1a4ac878539..3dcf2a7698c8e963fbbea67c3e60e11ddc09a4ec 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, @@ -250,7 +250,7 @@ async def test_light_color_temperature(hass, hk_driver, events): hass.states.async_set( entity_id, STATE_ON, - {ATTR_SUPPORTED_COLOR_MODES: ["color_temp"], ATTR_COLOR_TEMP: 190}, + {ATTR_SUPPORTED_COLOR_MODES: ["color_temp"], ATTR_COLOR_TEMP_KELVIN: 5263}, ) await hass.async_block_till_done() acc = Light(hass, hk_driver, "Light", entity_id, 1, None) @@ -282,7 +282,7 @@ async def test_light_color_temperature(hass, hk_driver, events): await _wait_for_light_coalesce(hass) assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id - assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 + assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000 assert len(events) == 1 assert events[-1].data[ATTR_VALUE] == "color temperature at 250" @@ -302,7 +302,7 @@ async def test_light_color_temperature_and_rgb_color( STATE_ON, { ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, - ATTR_COLOR_TEMP: 190, + ATTR_COLOR_TEMP_KELVIN: 5263, ATTR_HS_COLOR: (260, 90), }, ) @@ -316,7 +316,7 @@ async def test_light_color_temperature_and_rgb_color( assert hasattr(acc, "char_color_temp") - hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 224}) + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 4464}) await hass.async_block_till_done() await acc.run() await hass.async_block_till_done() @@ -324,7 +324,7 @@ async def test_light_color_temperature_and_rgb_color( assert acc.char_hue.value == 27 assert acc.char_saturation.value == 27 - hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 352}) + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 2840}) await hass.async_block_till_done() await acc.run() await hass.async_block_till_done() @@ -373,7 +373,7 @@ async def test_light_color_temperature_and_rgb_color( assert call_turn_on[0] assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 - assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 + assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000 assert len(events) == 1 assert ( @@ -446,7 +446,7 @@ async def test_light_color_temperature_and_rgb_color( ) await _wait_for_light_coalesce(hass) assert call_turn_on[3] - assert call_turn_on[3].data[ATTR_COLOR_TEMP] == 320 + assert call_turn_on[3].data[ATTR_COLOR_TEMP_KELVIN] == 3125 assert events[-1].data[ATTR_VALUE] == "color temperature at 320" # Generate a conflict by setting color temp then saturation @@ -991,7 +991,7 @@ async def test_light_rgb_with_white_switch_to_temp( await _wait_for_light_coalesce(hass) assert call_turn_on assert call_turn_on[-1].data[ATTR_ENTITY_ID] == entity_id - assert call_turn_on[-1].data[ATTR_COLOR_TEMP] == 500 + assert call_turn_on[-1].data[ATTR_COLOR_TEMP_KELVIN] == 2000 assert len(events) == 2 assert events[-1].data[ATTR_VALUE] == "color temperature at 500" assert acc.char_brightness.value == 100 @@ -1335,7 +1335,7 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): await hass.async_block_till_done() assert acc.char_brightness.value == 40 - hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: (224.14)}) + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: (4461)}) await hass.async_block_till_done() assert acc.char_color_temp.value == 224 @@ -1364,7 +1364,7 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): assert call_turn_on[0] assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 - assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 + assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000 assert len(events) == 1 assert ( diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index dbdc2b0ba5501511c6d8f6ce8528b34ce4bdc42d..30b9bc77f5daa98c60c9bef003d6e408589d3288 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -442,7 +442,7 @@ async def test_tv_restore(hass, hk_driver, events): assert not hasattr(acc, "char_input_source") acc = TelevisionMediaPlayer( - hass, hk_driver, "MediaPlayer", "media_player.all_info_set", 2, None + hass, hk_driver, "MediaPlayer", "media_player.all_info_set", 3, None ) assert acc.category == 31 assert acc.chars_tv == [CHAR_REMOTE_KEY] diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index 64f1d82d1239bb24093b4b518e3bd58fa02586a0..920bf6d8a31667346964a56b6d2f07487b59ab28 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -282,13 +282,16 @@ async def test_supported_states(hass, hk_driver, events): }, ] + aid = 1 + for test_config in test_configs: attrs = {"supported_features": test_config.get("features")} hass.states.async_set(entity_id, None, attributes=attrs) await hass.async_block_till_done() - acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, config) + aid += 1 + acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, aid, config) await acc.run() await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index b916d447d12835a68299986e62ddeb58265f99a0..4997a35910d8aaa907d25be94ff399e065946f1b 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -423,12 +423,14 @@ async def test_motion_uses_bool(hass, hk_driver): async def test_binary_device_classes(hass, hk_driver): """Test if services and characteristics are assigned correctly.""" entity_id = "binary_sensor.demo" + aid = 1 for device_class, (service, char, _) in BINARY_SENSOR_SERVICE_MAP.items(): hass.states.async_set(entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: device_class}) await hass.async_block_till_done() - acc = BinarySensor(hass, hk_driver, "Binary Sensor", entity_id, 2, None) + aid += 1 + acc = BinarySensor(hass, hk_driver, "Binary Sensor", entity_id, aid, None) assert acc.get_service(service).display_name == service assert acc.char_detected.display_name == char @@ -460,7 +462,7 @@ async def test_sensor_restore(hass, hk_driver, events): acc = get_accessory(hass, hk_driver, hass.states.get("sensor.temperature"), 2, {}) assert acc.category == 10 - acc = get_accessory(hass, hk_driver, hass.states.get("sensor.humidity"), 2, {}) + acc = get_accessory(hass, hk_driver, hass.states.get("sensor.humidity"), 3, {}) assert acc.category == 10 diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index cc80201ae333a049c7577c74c620ff734447cf80..0d6f8f0d586ddf8acc6c088c479d01cd54fd1b1a 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -150,23 +150,23 @@ async def test_valve_set_state(hass, hk_driver, events): assert acc.category == 29 # Faucet assert acc.char_valve_type.value == 3 # Water faucet - acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_SHOWER}) + acc = Valve(hass, hk_driver, "Valve", entity_id, 3, {CONF_TYPE: TYPE_SHOWER}) await acc.run() await hass.async_block_till_done() assert acc.category == 30 # Shower assert acc.char_valve_type.value == 2 # Shower head - acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_SPRINKLER}) + acc = Valve(hass, hk_driver, "Valve", entity_id, 4, {CONF_TYPE: TYPE_SPRINKLER}) await acc.run() await hass.async_block_till_done() assert acc.category == 28 # Sprinkler assert acc.char_valve_type.value == 1 # Irrigation - acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_VALVE}) + acc = Valve(hass, hk_driver, "Valve", entity_id, 5, {CONF_TYPE: TYPE_VALVE}) await acc.run() await hass.async_block_till_done() - assert acc.aid == 2 + assert acc.aid == 5 assert acc.category == 29 # Faucet assert acc.char_active.value == 0 diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index a964568cc6045cd45e054b0b5da33bfc827eef29..33b45e54081777baa48cddce4e6887bb5c9ab0a8 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1045,7 +1045,7 @@ async def test_thermostat_restore(hass, hk_driver, events): "off", } - acc = Thermostat(hass, hk_driver, "Climate", "climate.all_info_set", 2, None) + acc = Thermostat(hass, hk_driver, "Climate", "climate.all_info_set", 3, None) assert acc.category == 9 assert acc.get_temperature_range() == (60.0, 70.0) assert set(acc.char_target_heat_cool.properties["ValidValues"].keys()) == { @@ -1859,7 +1859,7 @@ async def test_water_heater_restore(hass, hk_driver, events): } acc = WaterHeater( - hass, hk_driver, "WaterHeater", "water_heater.all_info_set", 2, None + hass, hk_driver, "WaterHeater", "water_heater.all_info_set", 3, None ) assert acc.category == 9 assert acc.get_temperature_range() == (60.0, 70.0) diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 07cc2b5cae74046dbe86ac97bbd248d5d947040b..b30ba6236a96bd097c0afbc5e827366550c7e3e9 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -9,7 +9,12 @@ import os from typing import Any, Final from unittest import mock -from aiohomekit.model import Accessories, AccessoriesState, Accessory +from aiohomekit.model import ( + Accessories, + AccessoriesState, + Accessory, + mixin as model_mixin, +) from aiohomekit.testing import FakeController, FakePairing from aiohomekit.zeroconf import HomeKitService @@ -19,7 +24,6 @@ from homeassistant.components.homekit_controller.const import ( DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, IDENTIFIER_ACCESSORY_ID, - IDENTIFIER_SERIAL_NUMBER, ) from homeassistant.components.homekit_controller.utils import async_get_controller from homeassistant.config_entries import ConfigEntry @@ -320,7 +324,6 @@ async def assert_devices_and_entities_created( device = device_registry.async_get_device( { - (IDENTIFIER_SERIAL_NUMBER, expected.serial_number), (IDENTIFIER_ACCESSORY_ID, expected.unique_id), } ) @@ -336,21 +339,15 @@ async def assert_devices_and_entities_created( # We might have matched the device by one identifier only # Lets check that the other one is correct. Otherwise the test might silently be wrong. - serial_number_set = False accessory_id_set = False for key, value in device.identifiers: - if key == IDENTIFIER_SERIAL_NUMBER: - assert value == expected.serial_number - serial_number_set = True - - elif key == IDENTIFIER_ACCESSORY_ID: + if key == IDENTIFIER_ACCESSORY_ID: assert value == expected.unique_id accessory_id_set = True # If unique_id or serial is provided it MUST actually appear in the device registry entry. assert (not expected.unique_id) ^ accessory_id_set - assert (not expected.serial_number) ^ serial_number_set for entity_info in expected.entities: entity = entity_registry.async_get(entity_info.entity_id) @@ -410,3 +407,8 @@ async def remove_device(ws_client, device_id, config_entry_id): ) response = await ws_client.receive_json() return response["success"] + + +def get_next_aid(): + """Get next aid.""" + return model_mixin.id_counter + 1 diff --git a/tests/components/homekit_controller/fixtures/netatmo_home_coach.json b/tests/components/homekit_controller/fixtures/netatmo_home_coach.json new file mode 100644 index 0000000000000000000000000000000000000000..b17c1bc542cd97701305358c33f7d6b8aa9f5382 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/netatmo_home_coach.json @@ -0,0 +1,249 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Healthy Home Coach", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "Netatmo", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "Healthy Home Coach", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "AAAAAAAAAAAAA", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pr"], + "format": "string", + "value": "59", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 7, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 25, + "type": "000000A2-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000037-0000-1000-8000-0026BB765291", + "iid": 26, + "perms": ["pr"], + "format": "string", + "value": "1.1.0", + "description": "Version", + "maxLen": 64 + } + ] + }, + { + "iid": 27, + "type": "EA22EA53-6227-55EA-AC24-73ACF3EEA0E8", + "characteristics": [ + { + "type": "4D05AE82-5A22-5BD6-A730-B7F8B4F3218D", + "iid": 28, + "perms": ["pw"], + "format": "bool" + }, + { + "type": "00F44C18-042E-5C4E-9A4C-561D44DCD804", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "g262d1a", + "maxLen": 64 + } + ] + }, + { + "iid": 24, + "type": "0000008D-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000095-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Air Quality", + "minValue": 0, + "maxValue": 5 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 9, + "perms": ["pr"], + "format": "string", + "value": "Air quality sensor", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 10, + "type": "00000097-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000092-0000-1000-8000-0026BB765291", + "iid": 11, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Carbon Dioxide Detected", + "minValue": 0, + "maxValue": 1 + }, + { + "type": "00000093-0000-1000-8000-0026BB765291", + "iid": 12, + "perms": ["pr", "ev"], + "format": "float", + "value": 804, + "description": "Carbon Dioxide Level", + "minValue": 0, + "maxValue": 10000 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 13, + "perms": ["pr"], + "format": "string", + "value": "Carbon Dioxide sensor", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 14, + "type": "00000082-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000010-0000-1000-8000-0026BB765291", + "iid": 15, + "perms": ["pr", "ev"], + "format": "float", + "value": 59, + "description": "Current Relative Humidity", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 16, + "perms": ["pr"], + "format": "string", + "value": "Humidity sensor", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 17, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 18, + "perms": ["pr", "ev"], + "format": "float", + "value": 22.9, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 50, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 19, + "perms": ["pr"], + "format": "string", + "value": "Temperature sensor", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 20, + "type": "6237CEFC-9F4D-54B2-8033-2EDA0053B811", + "characteristics": [ + { + "type": "B3BBFABC-D78C-5B8D-948C-5DAC1EE2CDE5", + "iid": 21, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 200, + "minStep": 1 + }, + { + "type": "627EA399-29D9-5DC8-9A02-08AE928F73D8", + "iid": 22, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 5, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 23, + "perms": ["pr"], + "format": "string", + "value": "Noise sensor", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py b/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py index 644abb8a3a6704a200b63186df592d94defaa126..f2e209a9fdb46cc1d1915725eedd11f9368915c9 100644 --- a/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py +++ b/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py @@ -39,7 +39,7 @@ async def test_eufycam_setup(hass): EntityTestInfo( entity_id="camera.eufycam2_0000", friendly_name="eufyCam2-0000", - unique_id="homekit-A0000A000000000D-aid:4", + unique_id="00:00:00:00:00:00_4", state="idle", ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py index 75423f3373e1ca702ac3cc25270d6c10595d23ba..7df51316ceb0149e7f926fdd762cc064e3c9b42a 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py @@ -37,7 +37,7 @@ async def test_aqara_gateway_setup(hass): EntityTestInfo( "alarm_control_panel.aqara_hub_1563_security_system", friendly_name="Aqara Hub-1563 Security System", - unique_id="homekit-0000000123456789-66304", + unique_id="00:00:00:00:00:00_1_66304", supported_features=AlarmControlPanelEntityFeature.ARM_NIGHT | AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY, @@ -46,7 +46,7 @@ async def test_aqara_gateway_setup(hass): EntityTestInfo( "light.aqara_hub_1563_lightbulb_1563", friendly_name="Aqara Hub-1563 Lightbulb-1563", - unique_id="homekit-0000000123456789-65792", + unique_id="00:00:00:00:00:00_1_65792", supported_features=0, capabilities={"supported_color_modes": ["hs"]}, state="off", @@ -54,7 +54,7 @@ async def test_aqara_gateway_setup(hass): EntityTestInfo( "number.aqara_hub_1563_volume", friendly_name="Aqara Hub-1563 Volume", - unique_id="homekit-0000000123456789-aid:1-sid:65536-cid:65541", + unique_id="00:00:00:00:00:00_1_65536_65541", capabilities={ "max": 100, "min": 0, @@ -67,7 +67,7 @@ async def test_aqara_gateway_setup(hass): EntityTestInfo( "switch.aqara_hub_1563_pairing_mode", friendly_name="Aqara Hub-1563 Pairing Mode", - unique_id="homekit-0000000123456789-aid:1-sid:65536-cid:65538", + unique_id="00:00:00:00:00:00_1_65536_65538", entity_category=EntityCategory.CONFIG, state="off", ), @@ -96,7 +96,7 @@ async def test_aqara_gateway_e1_setup(hass): EntityTestInfo( "alarm_control_panel.aqara_hub_e1_00a0_security_system", friendly_name="Aqara-Hub-E1-00A0 Security System", - unique_id="homekit-00aa00000a0-16", + unique_id="00:00:00:00:00:00_1_16", supported_features=AlarmControlPanelEntityFeature.ARM_NIGHT | AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY, @@ -105,7 +105,7 @@ async def test_aqara_gateway_e1_setup(hass): EntityTestInfo( "number.aqara_hub_e1_00a0_volume", friendly_name="Aqara-Hub-E1-00A0 Volume", - unique_id="homekit-00aa00000a0-aid:1-sid:17-cid:1114116", + unique_id="00:00:00:00:00:00_1_17_1114116", capabilities={ "max": 100, "min": 0, @@ -118,7 +118,7 @@ async def test_aqara_gateway_e1_setup(hass): EntityTestInfo( "switch.aqara_hub_e1_00a0_pairing_mode", friendly_name="Aqara-Hub-E1-00A0 Pairing Mode", - unique_id="homekit-00aa00000a0-aid:1-sid:17-cid:1114117", + unique_id="00:00:00:00:00:00_1_17_1114117", entity_category=EntityCategory.CONFIG, state="off", ), diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_switch.py b/tests/components/homekit_controller/specific_devices/test_aqara_switch.py index 793fb49af5b96dd74c1cc456853f22e3f2736ccf..6472d9939748ce83a760e5e608eb6afacc65c261 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_switch.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_switch.py @@ -42,7 +42,7 @@ async def test_aqara_switch_setup(hass): EntityTestInfo( entity_id="sensor.programmable_switch_battery_sensor", friendly_name="Programmable Switch Battery Sensor", - unique_id="homekit-111a1111a1a111-5", + unique_id="00:00:00:00:00:00_1_5", capabilities={"state_class": SensorStateClass.MEASUREMENT}, entity_category=EntityCategory.DIAGNOSTIC, unit_of_measurement=PERCENTAGE, diff --git a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py index 1b2b4bda3d653ad3f2656166bfaa7edea3b2dcc1..26c0c87e3b317ebca19082da9ebfa63a3130155c 100644 --- a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py +++ b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py @@ -33,19 +33,19 @@ async def test_arlo_baby_setup(hass): entities=[ EntityTestInfo( entity_id="camera.arlobabya0", - unique_id="homekit-00A0000000000-aid:1", + unique_id="00:00:00:00:00:00_1", friendly_name="ArloBabyA0", state="idle", ), EntityTestInfo( entity_id="binary_sensor.arlobabya0_motion", - unique_id="homekit-00A0000000000-500", + unique_id="00:00:00:00:00:00_1_500", friendly_name="ArloBabyA0 Motion", state="off", ), EntityTestInfo( entity_id="sensor.arlobabya0_battery", - unique_id="homekit-00A0000000000-700", + unique_id="00:00:00:00:00:00_1_700", friendly_name="ArloBabyA0 Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, @@ -54,7 +54,7 @@ async def test_arlo_baby_setup(hass): ), EntityTestInfo( entity_id="sensor.arlobabya0_humidity", - unique_id="homekit-00A0000000000-900", + unique_id="00:00:00:00:00:00_1_900", friendly_name="ArloBabyA0 Humidity", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=PERCENTAGE, @@ -62,7 +62,7 @@ async def test_arlo_baby_setup(hass): ), EntityTestInfo( entity_id="sensor.arlobabya0_temperature", - unique_id="homekit-00A0000000000-1000", + unique_id="00:00:00:00:00:00_1_1000", friendly_name="ArloBabyA0 Temperature", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=TEMP_CELSIUS, @@ -70,14 +70,14 @@ async def test_arlo_baby_setup(hass): ), EntityTestInfo( entity_id="sensor.arlobabya0_air_quality", - unique_id="homekit-00A0000000000-aid:1-sid:800-cid:802", + unique_id="00:00:00:00:00:00_1_800_802", capabilities={"state_class": SensorStateClass.MEASUREMENT}, friendly_name="ArloBabyA0 Air Quality", state="1", ), EntityTestInfo( entity_id="light.arlobabya0_nightlight", - unique_id="homekit-00A0000000000-1100", + unique_id="00:00:00:00:00:00_1_1100", friendly_name="ArloBabyA0 Nightlight", supported_features=0, capabilities={"supported_color_modes": ["hs"]}, diff --git a/tests/components/homekit_controller/specific_devices/test_connectsense.py b/tests/components/homekit_controller/specific_devices/test_connectsense.py index 9e233ebdc10af1943b81d9756cf0a4403871faf1..096ed39a3368cf444649ebe0615c1d3b34cb4838 100644 --- a/tests/components/homekit_controller/specific_devices/test_connectsense.py +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -37,7 +37,7 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="sensor.inwall_outlet_0394de_current", friendly_name="InWall Outlet-0394DE Current", - unique_id="homekit-1020301376-aid:1-sid:13-cid:18", + unique_id="00:00:00:00:00:00_1_13_18", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state="0.03", @@ -45,7 +45,7 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="sensor.inwall_outlet_0394de_power", friendly_name="InWall Outlet-0394DE Power", - unique_id="homekit-1020301376-aid:1-sid:13-cid:19", + unique_id="00:00:00:00:00:00_1_13_19", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=POWER_WATT, state="0.8", @@ -53,7 +53,7 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="sensor.inwall_outlet_0394de_energy_kwh", friendly_name="InWall Outlet-0394DE Energy kWh", - unique_id="homekit-1020301376-aid:1-sid:13-cid:20", + unique_id="00:00:00:00:00:00_1_13_20", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=ENERGY_KILO_WATT_HOUR, state="379.69299", @@ -61,13 +61,13 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="switch.inwall_outlet_0394de_outlet_a", friendly_name="InWall Outlet-0394DE Outlet A", - unique_id="homekit-1020301376-13", + unique_id="00:00:00:00:00:00_1_13", state="on", ), EntityTestInfo( entity_id="sensor.inwall_outlet_0394de_current_2", friendly_name="InWall Outlet-0394DE Current", - unique_id="homekit-1020301376-aid:1-sid:25-cid:30", + unique_id="00:00:00:00:00:00_1_25_30", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=ELECTRIC_CURRENT_AMPERE, state="0.05", @@ -75,7 +75,7 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="sensor.inwall_outlet_0394de_power_2", friendly_name="InWall Outlet-0394DE Power", - unique_id="homekit-1020301376-aid:1-sid:25-cid:31", + unique_id="00:00:00:00:00:00_1_25_31", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=POWER_WATT, state="0.8", @@ -83,7 +83,7 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="sensor.inwall_outlet_0394de_energy_kwh_2", friendly_name="InWall Outlet-0394DE Energy kWh", - unique_id="homekit-1020301376-aid:1-sid:25-cid:32", + unique_id="00:00:00:00:00:00_1_25_32", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=ENERGY_KILO_WATT_HOUR, state="175.85001", @@ -91,7 +91,7 @@ async def test_connectsense_setup(hass): EntityTestInfo( entity_id="switch.inwall_outlet_0394de_outlet_b", friendly_name="InWall Outlet-0394DE Outlet B", - unique_id="homekit-1020301376-25", + unique_id="00:00:00:00:00:00_1_25", state="on", ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 69a7d4f809c3e519929a0ffde477026ee5b0882d..299b8d24a9bc2caea0d89a634a08367860053b26 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -60,7 +60,7 @@ async def test_ecobee3_setup(hass): EntityTestInfo( entity_id="binary_sensor.kitchen", friendly_name="Kitchen", - unique_id="homekit-AB1C-56", + unique_id="00:00:00:00:00:00_2_56", state="off", ), ], @@ -78,7 +78,7 @@ async def test_ecobee3_setup(hass): EntityTestInfo( entity_id="binary_sensor.porch", friendly_name="Porch", - unique_id="homekit-AB2C-56", + unique_id="00:00:00:00:00:00_3_56", state="off", ), ], @@ -96,7 +96,7 @@ async def test_ecobee3_setup(hass): EntityTestInfo( entity_id="binary_sensor.basement", friendly_name="Basement", - unique_id="homekit-AB3C-56", + unique_id="00:00:00:00:00:00_4_56", state="off", ), ], @@ -106,7 +106,7 @@ async def test_ecobee3_setup(hass): EntityTestInfo( entity_id="climate.homew", friendly_name="HomeW", - unique_id="homekit-123456789012-16", + unique_id="00:00:00:00:00:00_1_16", supported_features=( SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE @@ -124,7 +124,7 @@ async def test_ecobee3_setup(hass): EntityTestInfo( entity_id="sensor.homew_current_temperature", friendly_name="HomeW Current Temperature", - unique_id="homekit-123456789012-aid:1-sid:16-cid:19", + unique_id="00:00:00:00:00:00_1_16_19", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=TEMP_CELSIUS, state="21.8", @@ -132,7 +132,7 @@ async def test_ecobee3_setup(hass): EntityTestInfo( entity_id="select.homew_current_mode", friendly_name="HomeW Current Mode", - unique_id="homekit-123456789012-aid:1-sid:16-cid:33", + unique_id="00:00:00:00:00:00_1_16_33", capabilities={"options": ["home", "sleep", "away"]}, state="home", ), @@ -164,16 +164,16 @@ async def test_ecobee3_setup_from_cache(hass, hass_storage): entity_registry = er.async_get(hass) climate = entity_registry.async_get("climate.homew") - assert climate.unique_id == "homekit-123456789012-16" + assert climate.unique_id == "00:00:00:00:00:00_1_16" occ1 = entity_registry.async_get("binary_sensor.kitchen") - assert occ1.unique_id == "homekit-AB1C-56" + assert occ1.unique_id == "00:00:00:00:00:00_2_56" occ2 = entity_registry.async_get("binary_sensor.porch") - assert occ2.unique_id == "homekit-AB2C-56" + assert occ2.unique_id == "00:00:00:00:00:00_3_56" occ3 = entity_registry.async_get("binary_sensor.basement") - assert occ3.unique_id == "homekit-AB3C-56" + assert occ3.unique_id == "00:00:00:00:00:00_4_56" async def test_ecobee3_setup_connection_failure(hass): @@ -204,16 +204,16 @@ async def test_ecobee3_setup_connection_failure(hass): await time_changed(hass, 5 * 60) climate = entity_registry.async_get("climate.homew") - assert climate.unique_id == "homekit-123456789012-16" + assert climate.unique_id == "00:00:00:00:00:00_1_16" occ1 = entity_registry.async_get("binary_sensor.kitchen") - assert occ1.unique_id == "homekit-AB1C-56" + assert occ1.unique_id == "00:00:00:00:00:00_2_56" occ2 = entity_registry.async_get("binary_sensor.porch") - assert occ2.unique_id == "homekit-AB2C-56" + assert occ2.unique_id == "00:00:00:00:00:00_3_56" occ3 = entity_registry.async_get("binary_sensor.basement") - assert occ3.unique_id == "homekit-AB3C-56" + assert occ3.unique_id == "00:00:00:00:00:00_4_56" async def test_ecobee3_add_sensors_at_runtime(hass): @@ -226,7 +226,7 @@ async def test_ecobee3_add_sensors_at_runtime(hass): await setup_test_accessories(hass, accessories) climate = entity_registry.async_get("climate.homew") - assert climate.unique_id == "homekit-123456789012-16" + assert climate.unique_id == "00:00:00:00:00:00_1_16" occ1 = entity_registry.async_get("binary_sensor.kitchen") assert occ1 is None @@ -243,10 +243,10 @@ async def test_ecobee3_add_sensors_at_runtime(hass): await device_config_changed(hass, accessories) occ1 = entity_registry.async_get("binary_sensor.kitchen") - assert occ1.unique_id == "homekit-AB1C-56" + assert occ1.unique_id == "00:00:00:00:00:00_2_56" occ2 = entity_registry.async_get("binary_sensor.porch") - assert occ2.unique_id == "homekit-AB2C-56" + assert occ2.unique_id == "00:00:00:00:00:00_3_56" occ3 = entity_registry.async_get("binary_sensor.basement") - assert occ3.unique_id == "homekit-AB3C-56" + assert occ3.unique_id == "00:00:00:00:00:00_4_56" diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_501.py b/tests/components/homekit_controller/specific_devices/test_ecobee_501.py index cf498a61e8137c01b24864d86195ecc3ee24b0c7..3d508df3a9ebb814a013041fe8418e4adb1b5872 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_501.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee_501.py @@ -39,7 +39,7 @@ async def test_ecobee501_setup(hass): EntityTestInfo( entity_id="climate.my_ecobee", friendly_name="My ecobee", - unique_id="homekit-123456789016-16", + unique_id="00:00:00:00:00:00_1_16", supported_features=( SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE @@ -59,7 +59,7 @@ async def test_ecobee501_setup(hass): EntityTestInfo( entity_id="binary_sensor.my_ecobee_occupancy", friendly_name="My ecobee Occupancy", - unique_id="homekit-123456789016-57", + unique_id="00:00:00:00:00:00_1_57", unit_of_measurement=None, state=STATE_ON, ), diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py index 20dae666c69dc8d4527b5e3a7a6468c099bbfe00..88220279b0c45c5098d70400847981bb134e29be 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py @@ -34,7 +34,7 @@ async def test_ecobee_occupancy_setup(hass): EntityTestInfo( entity_id="binary_sensor.master_fan", friendly_name="Master Fan", - unique_id="homekit-111111111111-56", + unique_id="00:00:00:00:00:00_1_56", state="off", ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_eve_degree.py b/tests/components/homekit_controller/specific_devices/test_eve_degree.py index eab2de030dbbef7ef59be4e71fa38db51036464a..c1a73dc37fa03e6ce729b65627f0c7567135a755 100644 --- a/tests/components/homekit_controller/specific_devices/test_eve_degree.py +++ b/tests/components/homekit_controller/specific_devices/test_eve_degree.py @@ -34,7 +34,7 @@ async def test_eve_degree_setup(hass): entities=[ EntityTestInfo( entity_id="sensor.eve_degree_aa11_temperature", - unique_id="homekit-AA00A0A00000-22", + unique_id="00:00:00:00:00:00_1_22", friendly_name="Eve Degree AA11 Temperature", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=TEMP_CELSIUS, @@ -42,7 +42,7 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="sensor.eve_degree_aa11_humidity", - unique_id="homekit-AA00A0A00000-27", + unique_id="00:00:00:00:00:00_1_27", friendly_name="Eve Degree AA11 Humidity", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=PERCENTAGE, @@ -50,7 +50,7 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="sensor.eve_degree_aa11_air_pressure", - unique_id="homekit-AA00A0A00000-aid:1-sid:30-cid:32", + unique_id="00:00:00:00:00:00_1_30_32", friendly_name="Eve Degree AA11 Air Pressure", unit_of_measurement=PRESSURE_HPA, capabilities={"state_class": SensorStateClass.MEASUREMENT}, @@ -58,7 +58,7 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="sensor.eve_degree_aa11_battery", - unique_id="homekit-AA00A0A00000-17", + unique_id="00:00:00:00:00:00_1_17", friendly_name="Eve Degree AA11 Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, @@ -67,7 +67,7 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="number.eve_degree_aa11_elevation", - unique_id="homekit-AA00A0A00000-aid:1-sid:30-cid:33", + unique_id="00:00:00:00:00:00_1_30_33", friendly_name="Eve Degree AA11 Elevation", capabilities={ "max": 9000, diff --git a/tests/components/homekit_controller/specific_devices/test_eve_energy.py b/tests/components/homekit_controller/specific_devices/test_eve_energy.py index 292ab9c66ac529cfa01a641092a746fcd9b64833..e678b3bbbaaf23aeee6a805071409cf24aa655ec 100644 --- a/tests/components/homekit_controller/specific_devices/test_eve_energy.py +++ b/tests/components/homekit_controller/specific_devices/test_eve_energy.py @@ -19,7 +19,7 @@ from ..common import ( ) -async def test_eve_degree_setup(hass): +async def test_eve_energy_setup(hass): """Test that the accessory can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "eve_energy.json") await setup_test_accessories(hass, accessories) @@ -38,13 +38,13 @@ async def test_eve_degree_setup(hass): entities=[ EntityTestInfo( entity_id="switch.eve_energy_50ff", - unique_id="homekit-AA00A0A00000-28", + unique_id="00:00:00:00:00:00_1_28", friendly_name="Eve Energy 50FF", state="off", ), EntityTestInfo( entity_id="sensor.eve_energy_50ff_amps", - unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:33", + unique_id="00:00:00:00:00:00_1_28_33", friendly_name="Eve Energy 50FF Amps", unit_of_measurement=ELECTRIC_CURRENT_AMPERE, capabilities={"state_class": SensorStateClass.MEASUREMENT}, @@ -52,7 +52,7 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="sensor.eve_energy_50ff_volts", - unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:32", + unique_id="00:00:00:00:00:00_1_28_32", friendly_name="Eve Energy 50FF Volts", unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, @@ -60,7 +60,7 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="sensor.eve_energy_50ff_power", - unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:34", + unique_id="00:00:00:00:00:00_1_28_34", friendly_name="Eve Energy 50FF Power", unit_of_measurement=POWER_WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, @@ -68,22 +68,22 @@ async def test_eve_degree_setup(hass): ), EntityTestInfo( entity_id="sensor.eve_energy_50ff_energy_kwh", - unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:35", + unique_id="00:00:00:00:00:00_1_28_35", friendly_name="Eve Energy 50FF Energy kWh", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, + capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, unit_of_measurement=ENERGY_KILO_WATT_HOUR, state="0.28999999165535", ), EntityTestInfo( entity_id="switch.eve_energy_50ff_lock_physical_controls", - unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:36", + unique_id="00:00:00:00:00:00_1_28_36", friendly_name="Eve Energy 50FF Lock Physical Controls", entity_category=EntityCategory.CONFIG, state="off", ), EntityTestInfo( entity_id="button.eve_energy_50ff_identify", - unique_id="homekit-AA00A0A00000-aid:1-sid:1-cid:3", + unique_id="00:00:00:00:00:00_1_1_3", friendly_name="Eve Energy 50FF Identify", entity_category=EntityCategory.DIAGNOSTIC, state="unknown", diff --git a/tests/components/homekit_controller/specific_devices/test_haa_fan.py b/tests/components/homekit_controller/specific_devices/test_haa_fan.py index 2f01a2c404eece548f832eed5590d492143d00ec..33eb5e2497983d59d9323dd5075951022556bf60 100644 --- a/tests/components/homekit_controller/specific_devices/test_haa_fan.py +++ b/tests/components/homekit_controller/specific_devices/test_haa_fan.py @@ -46,7 +46,7 @@ async def test_haa_fan_setup(hass): EntityTestInfo( entity_id="switch.haa_c718b3", friendly_name="HAA-C718B3", - unique_id="homekit-C718B3-2-8", + unique_id="00:00:00:00:00:00_2_8", state="off", ) ], @@ -56,7 +56,7 @@ async def test_haa_fan_setup(hass): EntityTestInfo( entity_id="fan.haa_c718b3", friendly_name="HAA-C718B3", - unique_id="homekit-C718B3-1-8", + unique_id="00:00:00:00:00:00_1_8", state="on", supported_features=FanEntityFeature.SET_SPEED, capabilities={ @@ -66,14 +66,14 @@ async def test_haa_fan_setup(hass): EntityTestInfo( entity_id="button.haa_c718b3_setup", friendly_name="HAA-C718B3 Setup", - unique_id="homekit-C718B3-1-aid:1-sid:1010-cid:1012", + unique_id="00:00:00:00:00:00_1_1010_1012", entity_category=EntityCategory.CONFIG, state="unknown", ), EntityTestInfo( entity_id="button.haa_c718b3_update", friendly_name="HAA-C718B3 Update", - unique_id="homekit-C718B3-1-aid:1-sid:1010-cid:1011", + unique_id="00:00:00:00:00:00_1_1010_1011", entity_category=EntityCategory.CONFIG, state="unknown", ), diff --git a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py index 175e534f639f571e581ce0fc3c7aa91627bf7ea7..6848f4079b036967d19ce6b50abdaa86e1b71687 100644 --- a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py @@ -43,7 +43,7 @@ async def test_homeassistant_bridge_fan_setup(hass): EntityTestInfo( entity_id="fan.living_room_fan", friendly_name="Living Room Fan", - unique_id="homekit-fan.living_room_fan-8", + unique_id="00:00:00:00:00:00_1256851357_8", supported_features=( FanEntityFeature.DIRECTION | FanEntityFeature.SET_SPEED diff --git a/tests/components/homekit_controller/specific_devices/test_hue_bridge.py b/tests/components/homekit_controller/specific_devices/test_hue_bridge.py index 1092bb4f82c25ce03c3e3cc0cf06163f48a1ba3b..361bfbfe17869a67f5878b99b65ce60d0a45faf2 100644 --- a/tests/components/homekit_controller/specific_devices/test_hue_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_hue_bridge.py @@ -46,7 +46,7 @@ async def test_hue_bridge_setup(hass): capabilities={"state_class": SensorStateClass.MEASUREMENT}, friendly_name="Hue dimmer switch battery", entity_category=EntityCategory.DIAGNOSTIC, - unique_id="homekit-6623462389072572-644245094400", + unique_id="00:00:00:00:00:00_6623462389072572_644245094400", unit_of_measurement=PERCENTAGE, state="100", ) diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index 99f34491e868498d22cd8af98258387d82e41cd3..2e3102d8f13b1d9e148e363b660310c902798049 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -46,7 +46,7 @@ async def test_koogeek_ls1_setup(hass): EntityTestInfo( entity_id="light.koogeek_ls1_20833f_light_strip", friendly_name="Koogeek-LS1-20833F Light Strip", - unique_id="homekit-AAAA011111111111-7", + unique_id="00:00:00:00:00:00_1_7", supported_features=0, capabilities={"supported_color_modes": ["hs"]}, state="off", @@ -54,7 +54,7 @@ async def test_koogeek_ls1_setup(hass): EntityTestInfo( entity_id="button.koogeek_ls1_20833f_identify", friendly_name="Koogeek-LS1-20833F Identify", - unique_id="homekit-AAAA011111111111-aid:1-sid:1-cid:6", + unique_id="00:00:00:00:00:00_1_1_6", entity_category=EntityCategory.DIAGNOSTIC, state="unknown", ), diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py index 7df1cee54d5e3ff2177cb2f7fbaf6bb48937df73..ee8c273904c6dc4606aaeadfcea8d6221e582ee1 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py @@ -33,13 +33,13 @@ async def test_koogeek_p1eu_setup(hass): EntityTestInfo( entity_id="switch.koogeek_p1_a00aa0_outlet", friendly_name="Koogeek-P1-A00AA0 outlet", - unique_id="homekit-EUCP03190xxxxx48-7", + unique_id="00:00:00:00:00:00_1_7", state="off", ), EntityTestInfo( entity_id="sensor.koogeek_p1_a00aa0_power", friendly_name="Koogeek-P1-A00AA0 Power", - unique_id="homekit-EUCP03190xxxxx48-aid:1-sid:21-cid:22", + unique_id="00:00:00:00:00:00_1_21_22", unit_of_measurement=POWER_WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="5", diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py index 210fec0aafc4b1a698a77c8948eb1161e9e0d6b9..91edf91156ab705f2fb4d486ab080cb162044baf 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py @@ -39,19 +39,19 @@ async def test_koogeek_sw2_setup(hass): EntityTestInfo( entity_id="switch.koogeek_sw2_187a91_switch_1", friendly_name="Koogeek-SW2-187A91 Switch 1", - unique_id="homekit-CNNT061751001372-8", + unique_id="00:00:00:00:00:00_1_8", state="off", ), EntityTestInfo( entity_id="switch.koogeek_sw2_187a91_switch_2", friendly_name="Koogeek-SW2-187A91 Switch 2", - unique_id="homekit-CNNT061751001372-11", + unique_id="00:00:00:00:00:00_1_11", state="off", ), EntityTestInfo( entity_id="sensor.koogeek_sw2_187a91_power", friendly_name="Koogeek-SW2-187A91 Power", - unique_id="homekit-CNNT061751001372-aid:1-sid:14-cid:18", + unique_id="00:00:00:00:00:00_1_14_18", unit_of_measurement=POWER_WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="0", diff --git a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py index 1bb31241023f2f43c5dfa9e63950a4d1f3183f99..fb1c0d183d3ba15d677e6b49dac034ec8dc17fe7 100644 --- a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py +++ b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py @@ -39,7 +39,7 @@ async def test_lennox_e30_setup(hass): EntityTestInfo( entity_id="climate.lennox", friendly_name="Lennox", - unique_id="homekit-XXXXXXXX-100", + unique_id="00:00:00:00:00:00_1_100", supported_features=( SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE ), diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py index 22d29f7500ded8d08ef5574a6fe9cbf8d6962ab6..4af74e0cd86d11897344f428df56ff8b0d39ea35 100644 --- a/tests/components/homekit_controller/specific_devices/test_lg_tv.py +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -36,7 +36,7 @@ async def test_lg_tv(hass): EntityTestInfo( entity_id="media_player.lg_webos_tv_af80", friendly_name="LG webOS TV AF80", - unique_id="homekit-999AAAAAA999-48", + unique_id="00:00:00:00:00:00_1_48", supported_features=( SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE ), diff --git a/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py b/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py index 9df8cf4e5aef9b8b651b86fcf0e2005d39f6ce4b..76c5bc70bfff0f7fc8e9e411cf92bd95168fa8f9 100644 --- a/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py @@ -41,7 +41,7 @@ async def test_lutron_caseta_bridge_setup(hass): EntityTestInfo( entity_id="fan.caseta_r_wireless_fan_speed_control", friendly_name="Caséta® Wireless Fan Speed Control", - unique_id="homekit-39024290-2", + unique_id="00:00:00:00:00:00_21474836482_2", unit_of_measurement=None, supported_features=1, state=STATE_OFF, diff --git a/tests/components/homekit_controller/specific_devices/test_mss425f.py b/tests/components/homekit_controller/specific_devices/test_mss425f.py index 6db4140bd75cd30a35ca2c33049e856706d1e84c..86d8ebeca71cda6a6ccf37d27e760fb12f7630f2 100644 --- a/tests/components/homekit_controller/specific_devices/test_mss425f.py +++ b/tests/components/homekit_controller/specific_devices/test_mss425f.py @@ -34,38 +34,38 @@ async def test_meross_mss425f_setup(hass): EntityTestInfo( entity_id="button.mss425f_15cc_identify", friendly_name="MSS425F-15cc Identify", - unique_id="homekit-HH41234-aid:1-sid:1-cid:2", + unique_id="00:00:00:00:00:00_1_1_2", entity_category=EntityCategory.DIAGNOSTIC, state=STATE_UNKNOWN, ), EntityTestInfo( entity_id="switch.mss425f_15cc_outlet_1", friendly_name="MSS425F-15cc Outlet-1", - unique_id="homekit-HH41234-12", + unique_id="00:00:00:00:00:00_1_12", state=STATE_ON, ), EntityTestInfo( entity_id="switch.mss425f_15cc_outlet_2", friendly_name="MSS425F-15cc Outlet-2", - unique_id="homekit-HH41234-15", + unique_id="00:00:00:00:00:00_1_15", state=STATE_ON, ), EntityTestInfo( entity_id="switch.mss425f_15cc_outlet_3", friendly_name="MSS425F-15cc Outlet-3", - unique_id="homekit-HH41234-18", + unique_id="00:00:00:00:00:00_1_18", state=STATE_ON, ), EntityTestInfo( entity_id="switch.mss425f_15cc_outlet_4", friendly_name="MSS425F-15cc Outlet-4", - unique_id="homekit-HH41234-21", + unique_id="00:00:00:00:00:00_1_21", state=STATE_ON, ), EntityTestInfo( entity_id="switch.mss425f_15cc_usb", friendly_name="MSS425F-15cc USB", - unique_id="homekit-HH41234-24", + unique_id="00:00:00:00:00:00_1_24", state=STATE_ON, ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_mss565.py b/tests/components/homekit_controller/specific_devices/test_mss565.py index 1a9c5bbbf6fd5f14fbbb40eba03ab475881ce4d6..5140b563a9a4631075cb01e00347a48a0eb72a14 100644 --- a/tests/components/homekit_controller/specific_devices/test_mss565.py +++ b/tests/components/homekit_controller/specific_devices/test_mss565.py @@ -33,7 +33,7 @@ async def test_meross_mss565_setup(hass): EntityTestInfo( entity_id="light.mss565_28da_dimmer_switch", friendly_name="MSS565-28da Dimmer Switch", - unique_id="homekit-BB1121-12", + unique_id="00:00:00:00:00:00_1_12", capabilities={"supported_color_modes": ["brightness"]}, state=STATE_ON, ), diff --git a/tests/components/homekit_controller/specific_devices/test_mysa_living.py b/tests/components/homekit_controller/specific_devices/test_mysa_living.py index a5abe4ad2e7ed174934c00ee9230c7b8333227be..83404d9dd9965e85ac6c52eb36a85d2c152c1149 100644 --- a/tests/components/homekit_controller/specific_devices/test_mysa_living.py +++ b/tests/components/homekit_controller/specific_devices/test_mysa_living.py @@ -34,7 +34,7 @@ async def test_mysa_living_setup(hass): EntityTestInfo( entity_id="climate.mysa_85dda9_thermostat", friendly_name="Mysa-85dda9 Thermostat", - unique_id="homekit-AAAAAAA000-20", + unique_id="00:00:00:00:00:00_1_20", supported_features=ClimateEntityFeature.TARGET_TEMPERATURE, capabilities={ "hvac_modes": ["off", "heat", "cool", "heat_cool"], @@ -46,7 +46,7 @@ async def test_mysa_living_setup(hass): EntityTestInfo( entity_id="sensor.mysa_85dda9_current_humidity", friendly_name="Mysa-85dda9 Current Humidity", - unique_id="homekit-AAAAAAA000-aid:1-sid:20-cid:27", + unique_id="00:00:00:00:00:00_1_20_27", unit_of_measurement=PERCENTAGE, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="40", @@ -54,7 +54,7 @@ async def test_mysa_living_setup(hass): EntityTestInfo( entity_id="sensor.mysa_85dda9_current_temperature", friendly_name="Mysa-85dda9 Current Temperature", - unique_id="homekit-AAAAAAA000-aid:1-sid:20-cid:25", + unique_id="00:00:00:00:00:00_1_20_25", unit_of_measurement=TEMP_CELSIUS, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="24.1", @@ -62,7 +62,7 @@ async def test_mysa_living_setup(hass): EntityTestInfo( entity_id="light.mysa_85dda9_display", friendly_name="Mysa-85dda9 Display", - unique_id="homekit-AAAAAAA000-40", + unique_id="00:00:00:00:00:00_1_40", supported_features=0, capabilities={"supported_color_modes": ["brightness"]}, state="off", diff --git a/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py b/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py index c66ea0d76a927c7279b10a6743ab02016adf1c17..4afb61b19f358b15b9959b2a3fe6e673526c1fea 100644 --- a/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py +++ b/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py @@ -34,9 +34,11 @@ async def test_nanoleaf_nl55_setup(hass): EntityTestInfo( entity_id="light.nanoleaf_strip_3b32_nanoleaf_light_strip", friendly_name="Nanoleaf Strip 3B32 Nanoleaf Light Strip", - unique_id="homekit-AAAA011111111111-19", + unique_id="00:00:00:00:00:00_1_19", supported_features=0, capabilities={ + "max_color_temp_kelvin": 6535, + "min_color_temp_kelvin": 2127, "max_mireds": 470, "min_mireds": 153, "supported_color_modes": ["color_temp", "hs"], @@ -46,21 +48,21 @@ async def test_nanoleaf_nl55_setup(hass): EntityTestInfo( entity_id="button.nanoleaf_strip_3b32_identify", friendly_name="Nanoleaf Strip 3B32 Identify", - unique_id="homekit-AAAA011111111111-aid:1-sid:1-cid:2", + unique_id="00:00:00:00:00:00_1_1_2", entity_category=EntityCategory.DIAGNOSTIC, state="unknown", ), EntityTestInfo( entity_id="sensor.nanoleaf_strip_3b32_thread_capabilities", friendly_name="Nanoleaf Strip 3B32 Thread Capabilities", - unique_id="homekit-AAAA011111111111-aid:1-sid:31-cid:115", + unique_id="00:00:00:00:00:00_1_31_115", entity_category=EntityCategory.DIAGNOSTIC, state="border_router_capable", ), EntityTestInfo( entity_id="sensor.nanoleaf_strip_3b32_thread_status", friendly_name="Nanoleaf Strip 3B32 Thread Status", - unique_id="homekit-AAAA011111111111-aid:1-sid:31-cid:117", + unique_id="00:00:00:00:00:00_1_31_117", entity_category=EntityCategory.DIAGNOSTIC, state="border_router", ), diff --git a/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py b/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py index 188bbaffedd01d2467dd89ef0bdf49f02912eaaf..9ff84c4570119f902bb307034ddb02595e1c328c 100644 --- a/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py +++ b/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py @@ -35,7 +35,7 @@ async def test_netamo_doorbell_setup(hass): EntityTestInfo( entity_id="camera.netatmo_doorbell_g738658", friendly_name="Netatmo-Doorbell-g738658", - unique_id="homekit-g738658-aid:1", + unique_id="00:00:00:00:00:00_1", state="idle", ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py b/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py index b2c83a005f80ab7b8c9745ebb4d0e7b5b33db382..3f46ffdc9fae2d2d9acc2404c70147e669dc1b98 100644 --- a/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py +++ b/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py @@ -35,14 +35,14 @@ async def test_netamo_smart_co_alarm_setup(hass): EntityTestInfo( entity_id="binary_sensor.smart_co_alarm_carbon_monoxide_sensor", friendly_name="Smart CO Alarm Carbon Monoxide Sensor", - unique_id="homekit-1234-22", + unique_id="00:00:00:00:00:00_1_22", state="off", ), EntityTestInfo( entity_id="binary_sensor.smart_co_alarm_low_battery", friendly_name="Smart CO Alarm Low Battery", entity_category=EntityCategory.DIAGNOSTIC, - unique_id="homekit-1234-36", + unique_id="00:00:00:00:00:00_1_36", state="off", ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py b/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py new file mode 100644 index 0000000000000000000000000000000000000000..5769cc81129742bd7ab862b9a5dacc1f1d722ac5 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py @@ -0,0 +1,45 @@ +""" +Regression tests for Netamo Healthy Home Coach. + +https://github.com/home-assistant/core/issues/73360 +""" +from homeassistant.components.sensor import SensorStateClass + +from ..common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_netamo_smart_co_alarm_setup(hass): + """Test that a Netamo Smart CO Alarm can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "netatmo_home_coach.json") + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Healthy Home Coach", + model="Healthy Home Coach", + manufacturer="Netatmo", + sw_version="59", + hw_version="", + serial_number="1234", + devices=[], + entities=[ + EntityTestInfo( + entity_id="sensor.healthy_home_coach_noise", + friendly_name="Healthy Home Coach Noise", + unique_id="00:00:00:00:00:00_1_20_21", + state="0", + unit_of_measurement="dB", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + ), + ], + ), + ) diff --git a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py index ecea2cdafbb5389267735a731d4266c015fa454e..c93493f38b5ebd40856fd0f1e00ce4b67e33571f 100644 --- a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py +++ b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py @@ -34,49 +34,49 @@ async def test_rainmachine_pro_8_setup(hass): EntityTestInfo( entity_id="switch.rainmachine_00ce4a", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-512", + unique_id="00:00:00:00:00:00_1_512", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_2", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-768", + unique_id="00:00:00:00:00:00_1_768", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_3", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-1024", + unique_id="00:00:00:00:00:00_1_1024", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_4", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-1280", + unique_id="00:00:00:00:00:00_1_1280", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_5", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-1536", + unique_id="00:00:00:00:00:00_1_1536", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_6", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-1792", + unique_id="00:00:00:00:00:00_1_1792", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_7", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-2048", + unique_id="00:00:00:00:00:00_1_2048", state="off", ), EntityTestInfo( entity_id="switch.rainmachine_00ce4a_8", friendly_name="RainMachine-00ce4a", - unique_id="homekit-00aa0000aa0a-2304", + unique_id="00:00:00:00:00:00_1_2304", state="off", ), ], diff --git a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py b/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py index a0c84472429f407ede9329170a63f9bee3d447a8..1e572683ce3dc53a30238fb5c3a7c38f4d49d209 100644 --- a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py @@ -47,7 +47,7 @@ async def test_ryse_smart_bridge_setup(hass): EntityTestInfo( entity_id="cover.master_bath_south_ryse_shade", friendly_name="Master Bath South RYSE Shade", - unique_id="homekit-00:00:00:00:00:00-2-48", + unique_id="00:00:00:00:00:00_2_48", supported_features=RYSE_SUPPORTED_FEATURES, state="closed", ), @@ -56,7 +56,7 @@ async def test_ryse_smart_bridge_setup(hass): friendly_name="Master Bath South RYSE Shade Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-00:00:00:00:00:00-2-64", + unique_id="00:00:00:00:00:00_2_64", unit_of_measurement=PERCENTAGE, state="100", ), @@ -75,7 +75,7 @@ async def test_ryse_smart_bridge_setup(hass): EntityTestInfo( entity_id="cover.ryse_smartshade_ryse_shade", friendly_name="RYSE SmartShade RYSE Shade", - unique_id="homekit-00:00:00:00:00:00-3-48", + unique_id="00:00:00:00:00:00_3_48", supported_features=RYSE_SUPPORTED_FEATURES, state="open", ), @@ -84,7 +84,7 @@ async def test_ryse_smart_bridge_setup(hass): friendly_name="RYSE SmartShade RYSE Shade Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-00:00:00:00:00:00-3-64", + unique_id="00:00:00:00:00:00_3_64", unit_of_measurement=PERCENTAGE, state="100", ), @@ -126,7 +126,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): EntityTestInfo( entity_id="cover.lr_left_ryse_shade", friendly_name="LR Left RYSE Shade", - unique_id="homekit-00:00:00:00:00:00-2-48", + unique_id="00:00:00:00:00:00_2_48", supported_features=RYSE_SUPPORTED_FEATURES, state="closed", ), @@ -135,7 +135,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): friendly_name="LR Left RYSE Shade Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-00:00:00:00:00:00-2-64", + unique_id="00:00:00:00:00:00_2_64", unit_of_measurement=PERCENTAGE, state="89", ), @@ -154,7 +154,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): EntityTestInfo( entity_id="cover.lr_right_ryse_shade", friendly_name="LR Right RYSE Shade", - unique_id="homekit-00:00:00:00:00:00-3-48", + unique_id="00:00:00:00:00:00_3_48", supported_features=RYSE_SUPPORTED_FEATURES, state="closed", ), @@ -163,7 +163,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): friendly_name="LR Right RYSE Shade Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-00:00:00:00:00:00-3-64", + unique_id="00:00:00:00:00:00_3_64", unit_of_measurement=PERCENTAGE, state="100", ), @@ -182,7 +182,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): EntityTestInfo( entity_id="cover.br_left_ryse_shade", friendly_name="BR Left RYSE Shade", - unique_id="homekit-00:00:00:00:00:00-4-48", + unique_id="00:00:00:00:00:00_4_48", supported_features=RYSE_SUPPORTED_FEATURES, state="open", ), @@ -191,7 +191,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): friendly_name="BR Left RYSE Shade Battery", entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-00:00:00:00:00:00-4-64", + unique_id="00:00:00:00:00:00_4_64", unit_of_measurement=PERCENTAGE, state="100", ), @@ -210,7 +210,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): EntityTestInfo( entity_id="cover.rzss_ryse_shade", friendly_name="RZSS RYSE Shade", - unique_id="homekit-00:00:00:00:00:00-5-48", + unique_id="00:00:00:00:00:00_5_48", supported_features=RYSE_SUPPORTED_FEATURES, state="open", ), @@ -219,7 +219,7 @@ async def test_ryse_smart_bridge_four_shades_setup(hass): entity_category=EntityCategory.DIAGNOSTIC, capabilities={"state_class": SensorStateClass.MEASUREMENT}, friendly_name="RZSS RYSE Shade Battery", - unique_id="homekit-00:00:00:00:00:00-5-64", + unique_id="00:00:00:00:00:00_5_64", unit_of_measurement=PERCENTAGE, state="0", ), diff --git a/tests/components/homekit_controller/specific_devices/test_schlage_sense.py b/tests/components/homekit_controller/specific_devices/test_schlage_sense.py index 0a59ec6f70a0b2b8f03e63dcd25ec3b49b8b7fe2..e1b55f6bd8898c0e7f70c1afa55c871461a5fc00 100644 --- a/tests/components/homekit_controller/specific_devices/test_schlage_sense.py +++ b/tests/components/homekit_controller/specific_devices/test_schlage_sense.py @@ -31,7 +31,7 @@ async def test_schlage_sense_setup(hass): EntityTestInfo( entity_id="lock.sense_lock_mechanism", friendly_name="SENSE Lock Mechanism", - unique_id="homekit-AAAAAAA000-30", + unique_id="00:00:00:00:00:00_1_30", supported_features=0, state="unknown", ), diff --git a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py index ba24bdeef96e19b25e1717f50f3726b1c0cabeb1..9a5edeb45b28bd6ec9971489f2dac3ca08c05e7f 100644 --- a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py +++ b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py @@ -36,7 +36,7 @@ async def test_simpleconnect_fan_setup(hass): EntityTestInfo( entity_id="fan.simpleconnect_fan_06f674_hunter_fan", friendly_name="SIMPLEconnect Fan-06F674 Hunter Fan", - unique_id="homekit-1234567890abcd-8", + unique_id="00:00:00:00:00:00_1_8", supported_features=FanEntityFeature.DIRECTION | FanEntityFeature.SET_SPEED, capabilities={ diff --git a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py index 8a5102e0a87fd0fe067b72cb3b876fdbd91ecbbd..a82d995d4d18af3a20ba434878fcff9e0368a9f0 100644 --- a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py @@ -51,7 +51,7 @@ async def test_velux_cover_setup(hass): EntityTestInfo( entity_id="cover.velux_window_roof_window", friendly_name="VELUX Window Roof Window", - unique_id="homekit-1111111a114a111a-8", + unique_id="00:00:00:00:00:00_3_8", supported_features=CoverEntityFeature.CLOSE | CoverEntityFeature.SET_POSITION | CoverEntityFeature.OPEN, @@ -73,7 +73,7 @@ async def test_velux_cover_setup(hass): entity_id="sensor.velux_sensor_temperature_sensor", friendly_name="VELUX Sensor Temperature sensor", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-a11b111-8", + unique_id="00:00:00:00:00:00_2_8", unit_of_measurement=TEMP_CELSIUS, state="18.9", ), @@ -81,7 +81,7 @@ async def test_velux_cover_setup(hass): entity_id="sensor.velux_sensor_humidity_sensor", friendly_name="VELUX Sensor Humidity sensor", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-a11b111-11", + unique_id="00:00:00:00:00:00_2_11", unit_of_measurement=PERCENTAGE, state="58", ), @@ -89,7 +89,7 @@ async def test_velux_cover_setup(hass): entity_id="sensor.velux_sensor_carbon_dioxide_sensor", friendly_name="VELUX Sensor Carbon Dioxide sensor", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="homekit-a11b111-14", + unique_id="00:00:00:00:00:00_2_14", unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state="400", ), diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py index 7c3262f3098a58ebf850222f231f16d6ed95e085..b07a10cf17d7efd95e07a2095be0eeb1060b601f 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py @@ -36,7 +36,7 @@ async def test_vocolinc_flowerbud_setup(hass): EntityTestInfo( entity_id="humidifier.vocolinc_flowerbud_0d324b", friendly_name="VOCOlinc-Flowerbud-0d324b", - unique_id="homekit-AM01121849000327-30", + unique_id="00:00:00:00:00:00_1_30", supported_features=HumidifierEntityFeature.MODES, capabilities={ "available_modes": ["normal", "auto"], @@ -48,7 +48,7 @@ async def test_vocolinc_flowerbud_setup(hass): EntityTestInfo( entity_id="light.vocolinc_flowerbud_0d324b_mood_light", friendly_name="VOCOlinc-Flowerbud-0d324b Mood Light", - unique_id="homekit-AM01121849000327-9", + unique_id="00:00:00:00:00:00_1_9", supported_features=0, capabilities={"supported_color_modes": ["hs"]}, state="on", @@ -56,7 +56,7 @@ async def test_vocolinc_flowerbud_setup(hass): EntityTestInfo( entity_id="number.vocolinc_flowerbud_0d324b_spray_quantity", friendly_name="VOCOlinc-Flowerbud-0d324b Spray Quantity", - unique_id="homekit-AM01121849000327-aid:1-sid:30-cid:38", + unique_id="00:00:00:00:00:00_1_30_38", capabilities={ "max": 5, "min": 1, @@ -69,7 +69,7 @@ async def test_vocolinc_flowerbud_setup(hass): EntityTestInfo( entity_id="sensor.vocolinc_flowerbud_0d324b_current_humidity", friendly_name="VOCOlinc-Flowerbud-0d324b Current Humidity", - unique_id="homekit-AM01121849000327-aid:1-sid:30-cid:33", + unique_id="00:00:00:00:00:00_1_30_33", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unit_of_measurement=PERCENTAGE, state="45.0", diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py index 4037a44898e44ddf8ad89a39070ccb5823675aed..7ced8979c8a8ddf578157472c0f9a5cc9ab99eb5 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py @@ -2,6 +2,7 @@ from homeassistant.components.sensor import SensorStateClass from homeassistant.const import POWER_WATT +from homeassistant.helpers import entity_registry as er from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -15,6 +16,21 @@ from ..common import ( async def test_vocolinc_vp3_setup(hass): """Test that a VOCOlinc VP3 can be correctly setup in HA.""" + + entity_registry = er.async_get(hass) + outlet = entity_registry.async_get_or_create( + "switch", + "homekit_controller", + "homekit-EU0121203xxxxx07-48", + suggested_object_id="original_vocolinc_vp3_outlet", + ) + sensor = entity_registry.async_get_or_create( + "sensor", + "homekit_controller", + "homekit-EU0121203xxxxx07-aid:1-sid:48-cid:97", + suggested_object_id="original_vocolinc_vp3_power", + ) + accessories = await setup_accessories_from_file(hass, "vocolinc_vp3.json") await setup_test_accessories(hass, accessories) @@ -31,15 +47,15 @@ async def test_vocolinc_vp3_setup(hass): devices=[], entities=[ EntityTestInfo( - entity_id="switch.vocolinc_vp3_123456_outlet", + entity_id="switch.original_vocolinc_vp3_outlet", friendly_name="VOCOlinc-VP3-123456 Outlet", - unique_id="homekit-EU0121203xxxxx07-48", + unique_id="00:00:00:00:00:00_1_48", state="on", ), EntityTestInfo( - entity_id="sensor.vocolinc_vp3_123456_power", + entity_id="sensor.original_vocolinc_vp3_power", friendly_name="VOCOlinc-VP3-123456 Power", - unique_id="homekit-EU0121203xxxxx07-aid:1-sid:48-cid:97", + unique_id="00:00:00:00:00:00_1_48_97", unit_of_measurement=POWER_WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="0", @@ -47,3 +63,12 @@ async def test_vocolinc_vp3_setup(hass): ], ), ) + + assert ( + entity_registry.async_get(outlet.entity_id).unique_id + == "00:00:00:00:00:00_1_48" + ) + assert ( + entity_registry.async_get(sensor.entity_id).unique_id + == "00:00:00:00:00:00_1_48_97" + ) diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py index 46979bd41f3cafc8948cbf47e4562e67ba64adfd..2c2ff92ccb64cab967bd2d13268a20d0103698f0 100644 --- a/tests/components/homekit_controller/test_alarm_control_panel.py +++ b/tests/components/homekit_controller/test_alarm_control_panel.py @@ -2,7 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import get_next_aid, setup_test_component def create_security_system_service(accessory): @@ -119,3 +121,20 @@ async def test_switch_read_alarm_state(hass, utcnow): ) state = await helper.poll_and_get_state() assert state.state == "triggered" + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a alarm_control_panel unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + alarm_control_panel_entry = entity_registry.async_get_or_create( + "alarm_control_panel", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_security_system_service) + + assert ( + entity_registry.async_get(alarm_control_panel_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py index e9cd9284332b40d58400af9a01d6e91ffb7c3d49..7e926910da18157fc82498bad74696072dd07ebb 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -3,8 +3,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.helpers import entity_registry as er -from .common import setup_test_component +from .common import get_next_aid, setup_test_component def create_motion_sensor_service(accessory): @@ -169,3 +170,20 @@ async def test_leak_sensor_read_state(hass, utcnow): assert state.state == "on" assert state.attributes["device_class"] == BinarySensorDeviceClass.MOISTURE + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a binary_sensor unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + binary_sensor_entry = entity_registry.async_get_or_create( + "binary_sensor", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_leak_sensor_service) + + assert ( + entity_registry.async_get(binary_sensor_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_button.py b/tests/components/homekit_controller/test_button.py index 58c1feb8900679bdf0e9af33a1d3fec2216faf8d..77551668ea573a3092320d04289c0a95bcaa3eb7 100644 --- a/tests/components/homekit_controller/test_button.py +++ b/tests/components/homekit_controller/test_button.py @@ -2,7 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import Helper, setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import Helper, get_next_aid, setup_test_component def create_switch_with_setup_button(accessory): @@ -89,3 +91,19 @@ async def test_ecobee_clear_hold_press_button(hass): CharacteristicsTypes.VENDOR_ECOBEE_CLEAR_HOLD: True, }, ) + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a button unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + button_entry = entity_registry.async_get_or_create( + "button", + "homekit_controller", + f"homekit-0001-aid:{aid}-sid:1-cid:2", + ) + await setup_test_component(hass, create_switch_with_ecobee_clear_hold_button) + assert ( + entity_registry.async_get(button_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_1_2" + ) diff --git a/tests/components/homekit_controller/test_camera.py b/tests/components/homekit_controller/test_camera.py index e0ba609b30e5e913e817976401966c3025d13d11..f4207ca4ca98e01117f0faee1d3fd09df4c1e197 100644 --- a/tests/components/homekit_controller/test_camera.py +++ b/tests/components/homekit_controller/test_camera.py @@ -5,8 +5,9 @@ from aiohomekit.model.services import ServicesTypes from aiohomekit.testing import FAKE_CAMERA_IMAGE from homeassistant.components import camera +from homeassistant.helpers import entity_registry as er -from .common import setup_test_component +from .common import get_next_aid, setup_test_component def create_camera(accessory): @@ -14,6 +15,22 @@ def create_camera(accessory): accessory.add_service(ServicesTypes.CAMERA_RTP_STREAM_MANAGEMENT) +async def test_migrate_unique_ids(hass, utcnow): + """Test migrating entity unique ids.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + camera = entity_registry.async_get_or_create( + "camera", + "homekit_controller", + f"homekit-0001-aid:{aid}", + ) + await setup_test_component(hass, create_camera) + assert ( + entity_registry.async_get(camera.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}" + ) + + async def test_read_state(hass, utcnow): """Test reading the state of a HomeKit camera.""" helper = await setup_test_component(hass, create_camera) diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 0f669c9c51f87c8a61c67f56ba03028a6597b681..0f10f0f9fa0f4d9970cf25d99263b3fe40149711 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -17,8 +17,9 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) +from homeassistant.helpers import entity_registry as er -from .common import setup_test_component +from .common import get_next_aid, setup_test_component # Test thermostat devices @@ -943,3 +944,19 @@ async def test_heater_cooler_turn_off(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "off" assert state.attributes["hvac_action"] == "off" + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a switch unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + climate_entry = entity_registry.async_get_or_create( + "climate", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_heater_cooler_service) + assert ( + entity_registry.async_get(climate_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 9db07a45d166e5f4d47fdf20d596d7431b6994c3..b853989ab151c95169b61ae9e3e2ae95519c4f82 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -9,7 +9,6 @@ from homeassistant.components.homekit_controller.const import ( IDENTIFIER_ACCESSORY_ID, IDENTIFIER_LEGACY_ACCESSORY_ID, IDENTIFIER_LEGACY_SERIAL_NUMBER, - IDENTIFIER_SERIAL_NUMBER, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -36,7 +35,6 @@ DEVICE_MIGRATION_TESTS = [ manufacturer="RYSE Inc.", before={ (DOMAIN, IDENTIFIER_LEGACY_ACCESSORY_ID, "00:00:00:00:00:00"), - (DOMAIN, IDENTIFIER_LEGACY_SERIAL_NUMBER, "0401.3521.0679"), }, after={(IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:1")}, ), @@ -55,11 +53,9 @@ DEVICE_MIGRATION_TESTS = [ manufacturer="Philips Lighting", before={ (DOMAIN, IDENTIFIER_LEGACY_ACCESSORY_ID, "00:00:00:00:00:00"), - (DOMAIN, IDENTIFIER_LEGACY_SERIAL_NUMBER, "123456"), }, after={ (IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:1"), - (IDENTIFIER_SERIAL_NUMBER, "123456"), }, ), # Test migrating a Hue remote - it has a valid serial number @@ -72,7 +68,6 @@ DEVICE_MIGRATION_TESTS = [ }, after={ (IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:6623462389072572"), - (IDENTIFIER_SERIAL_NUMBER, "6623462389072572"), }, ), # Test migrating a Koogeek LS1. This is just for completeness (testing hub and hub-less devices) @@ -85,7 +80,6 @@ DEVICE_MIGRATION_TESTS = [ }, after={ (IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:1"), - (IDENTIFIER_SERIAL_NUMBER, "AAAA011111111111"), }, ), ] diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index 15422f2f0bcc5a040c531353cb4b72d5ce203f90..6ceb57f5e09c08301f99a38de8d1da8c85024b44 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -2,7 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import get_next_aid, setup_test_component def create_window_covering_service(accessory): @@ -277,3 +279,20 @@ async def test_read_door_state(hass, utcnow): ) state = await helper.poll_and_get_state() assert state.attributes["obstruction-detected"] is True + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a cover unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + cover_entry = entity_registry.async_get_or_create( + "cover", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_garage_door_opener_service) + + assert ( + entity_registry.async_get(cover_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index de13772b5a123588320ebcd7e5c6dadf46532c9f..855f426da13acb04695038fb722318eb063e7166 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -2,7 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import get_next_aid, setup_test_component def create_fan_service(accessory): @@ -805,3 +807,20 @@ async def test_v2_set_percentage_non_standard_rotation_range(hass, utcnow): CharacteristicsTypes.ACTIVE: 0, }, ) + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a fan unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + fan_entry = entity_registry.async_get_or_create( + "fan", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_fanv2_service_non_standard_rotation_range) + + assert ( + entity_registry.async_get(fan_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_humidifier.py b/tests/components/homekit_controller/test_humidifier.py index da981e9eac0eb48a3316ee914f440ecd3a5eceef..1128459c4a645a8983d8dba1260781c1fcea04ef 100644 --- a/tests/components/homekit_controller/test_humidifier.py +++ b/tests/components/homekit_controller/test_humidifier.py @@ -3,8 +3,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes from homeassistant.components.humidifier import DOMAIN, MODE_AUTO, MODE_NORMAL +from homeassistant.helpers import entity_registry as er -from .common import setup_test_component +from .common import get_next_aid, setup_test_component def create_humidifier_service(accessory): @@ -436,3 +437,20 @@ async def test_dehumidifier_target_humidity_modes(hass, utcnow): ) assert state.attributes["mode"] == "normal" assert state.attributes["humidity"] == 73 + + +async def test_migrate_entity_ids(hass, utcnow): + """Test that we can migrate humidifier entity ids.""" + aid = get_next_aid() + + entity_registry = er.async_get(hass) + humidifier_entry = entity_registry.async_get_or_create( + "humidifier", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_humidifier_service) + assert ( + entity_registry.async_get(humidifier_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index 726be15a32c0aa29eb057fb771e18740aa9c6f2b..31604f2b1dd7b1f2da1d71cf980a60f7b2d97b04 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -9,8 +9,9 @@ from homeassistant.components.light import ( ColorMode, ) from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_UNAVAILABLE +from homeassistant.helpers import entity_registry as er -from .common import setup_test_component +from .common import get_next_aid, setup_test_component LIGHT_BULB_NAME = "TestDevice" LIGHT_BULB_ENTITY_ID = "light.testdevice" @@ -335,3 +336,47 @@ async def test_light_unloaded_removed(hass, utcnow): # Make sure entity is removed assert hass.states.get(helper.entity_id).state == STATE_UNAVAILABLE + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a light unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + light_entry = entity_registry.async_get_or_create( + "light", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_lightbulb_service_with_color_temp) + + assert ( + entity_registry.async_get(light_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) + + +async def test_only_migrate_once(hass, utcnow): + """Test a we handle migration happening after an upgrade and than a downgrade and then an upgrade.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + old_light_entry = entity_registry.async_get_or_create( + "light", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + new_light_entry = entity_registry.async_get_or_create( + "light", + "homekit_controller", + f"00:00:00:00:00:00_{aid}_8", + ) + await setup_test_component(hass, create_lightbulb_service_with_color_temp) + + assert ( + entity_registry.async_get(old_light_entry.entity_id).unique_id + == f"homekit-00:00:00:00:00:00-{aid}-8" + ) + + assert ( + entity_registry.async_get(new_light_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py index af21f26a012de4195f27a1460dec14b239fe1b68..719ff66c766dee086793c96159664f56ebacb673 100644 --- a/tests/components/homekit_controller/test_lock.py +++ b/tests/components/homekit_controller/test_lock.py @@ -2,7 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import get_next_aid, setup_test_component def create_lock_service(accessory): @@ -112,3 +114,20 @@ async def test_switch_read_lock_state(hass, utcnow): ) state = await helper.poll_and_get_state() assert state.state == "unlocking" + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a lock unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + lock_entry = entity_registry.async_get_or_create( + "lock", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_lock_service) + + assert ( + entity_registry.async_get(lock_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py index 7fb8c4edb2a230432927a68c3c6df9a07a7e665b..829f28cf341e600dc4d0ef0d44c0c2e79745bc7d 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -6,7 +6,9 @@ from aiohomekit.model.characteristics import ( from aiohomekit.model.services import ServicesTypes import pytest -from .common import setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import get_next_aid, setup_test_component def create_tv_service(accessory): @@ -364,3 +366,20 @@ async def test_tv_set_source_fail(hass, utcnow): state = await helper.poll_and_get_state() assert state.attributes["source"] == "HDMI 1" + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a media_player unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + media_player_entry = entity_registry.async_get_or_create( + "media_player", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + await setup_test_component(hass, create_tv_service_with_target_media_state) + + assert ( + entity_registry.async_get(media_player_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) diff --git a/tests/components/homekit_controller/test_number.py b/tests/components/homekit_controller/test_number.py index 6d0604168612746b65e9f833ef52c746c875a8af..cc6950e0f90a96aa923d214c8277a0825faea6eb 100644 --- a/tests/components/homekit_controller/test_number.py +++ b/tests/components/homekit_controller/test_number.py @@ -2,7 +2,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import Helper, setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import Helper, get_next_aid, setup_test_component def create_switch_with_spray_level(accessory): @@ -26,6 +28,24 @@ def create_switch_with_spray_level(accessory): return service +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a number unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + number = entity_registry.async_get_or_create( + "number", + "homekit_controller", + f"homekit-0001-aid:{aid}-sid:8-cid:9", + suggested_object_id="testdevice_spray_quantity", + ) + await setup_test_component(hass, create_switch_with_spray_level) + + assert ( + entity_registry.async_get(number.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8_9" + ) + + async def test_read_number(hass, utcnow): """Test a switch service that has a sensor characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_spray_level) diff --git a/tests/components/homekit_controller/test_select.py b/tests/components/homekit_controller/test_select.py index 22cd53d7a314fa04c76a38e726d59d480e757ccc..d18f0b97ecc6425f05eb4d195a2b7932b990bc83 100644 --- a/tests/components/homekit_controller/test_select.py +++ b/tests/components/homekit_controller/test_select.py @@ -3,7 +3,9 @@ from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from .common import Helper, setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import Helper, get_next_aid, setup_test_component def create_service_with_ecobee_mode(accessory: Accessory): @@ -19,6 +21,25 @@ def create_service_with_ecobee_mode(accessory: Accessory): return service +async def test_migrate_unique_id(hass, utcnow): + """Test we can migrate a select unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + select = entity_registry.async_get_or_create( + "select", + "homekit_controller", + f"homekit-0001-aid:{aid}-sid:8-cid:14", + suggested_object_id="testdevice_current_mode", + ) + + await setup_test_component(hass, create_service_with_ecobee_mode) + + assert ( + entity_registry.async_get(select.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8_14" + ) + + async def test_read_current_mode(hass, utcnow): """Test that Ecobee mode can be correctly read and show as human readable text.""" helper = await setup_test_component(hass, create_service_with_ecobee_mode) diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index 4bd6612026c74ced438d9e21e530f01120754ac4..b769a91608297698377a632c3e4792b33b0407c0 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.homekit_controller.sensor import ( thread_status_to_str, ) from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.helpers import entity_registry as er from .common import TEST_DEVICE_SERVICE_INFO, Helper, setup_test_component @@ -361,7 +362,6 @@ async def test_rssi_sensor( hass, utcnow, entity_registry_enabled_by_default, enable_bluetooth ): """Test an rssi sensor.""" - inject_bluetooth_service_info(hass, TEST_DEVICE_SERVICE_INFO) class FakeBLEPairing(FakePairing): @@ -378,3 +378,38 @@ async def test_rssi_sensor( hass, create_battery_level_sensor, suffix="battery", connection="BLE" ) assert hass.states.get("sensor.testdevice_signal_strength").state == "-56" + + +async def test_migrate_rssi_sensor_unique_id( + hass, utcnow, entity_registry_enabled_by_default, enable_bluetooth +): + """Test an rssi sensor unique id migration.""" + entity_registry = er.async_get(hass) + rssi_sensor = entity_registry.async_get_or_create( + "sensor", + "homekit_controller", + "homekit-0001-rssi", + suggested_object_id="renamed_rssi", + ) + + inject_bluetooth_service_info(hass, TEST_DEVICE_SERVICE_INFO) + + class FakeBLEPairing(FakePairing): + """Fake BLE pairing.""" + + @property + def transport(self): + return Transport.BLE + + with patch("aiohomekit.testing.FakePairing", FakeBLEPairing): + # Any accessory will do for this test, but we need at least + # one or the rssi sensor will not be created + await setup_test_component( + hass, create_battery_level_sensor, suffix="battery", connection="BLE" + ) + assert hass.states.get("sensor.renamed_rssi").state == "-56" + + assert ( + entity_registry.async_get(rssi_sensor.entity_id).unique_id + == "00:00:00:00:00:00_rssi" + ) diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py index a034624bd6070e521d65a53de6b8a13121e732a5..1e9b1cab730a9dff4a90f9079b1f04e6056ad0b8 100644 --- a/tests/components/homekit_controller/test_switch.py +++ b/tests/components/homekit_controller/test_switch.py @@ -7,7 +7,9 @@ from aiohomekit.model.characteristics import ( ) from aiohomekit.model.services import ServicesTypes -from .common import setup_test_component +from homeassistant.helpers import entity_registry as er + +from .common import get_next_aid, setup_test_component def create_switch_service(accessory): @@ -215,3 +217,30 @@ async def test_char_switch_read_state(hass, utcnow): {CharacteristicsTypes.VENDOR_AQARA_PAIRING_MODE: False}, ) assert switch_1.state == "off" + + +async def test_migrate_unique_id(hass, utcnow): + """Test a we can migrate a switch unique id.""" + entity_registry = er.async_get(hass) + aid = get_next_aid() + switch_entry = entity_registry.async_get_or_create( + "switch", + "homekit_controller", + f"homekit-00:00:00:00:00:00-{aid}-8", + ) + switch_entry_2 = entity_registry.async_get_or_create( + "switch", + "homekit_controller", + f"homekit-0001-aid:{aid}-sid:8-cid:9", + ) + await setup_test_component(hass, create_char_switch_service, suffix="pairing_mode") + + assert ( + entity_registry.async_get(switch_entry.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8" + ) + + assert ( + entity_registry.async_get(switch_entry_2.entity_id).unique_id + == f"00:00:00:00:00:00_{aid}_8_9" + ) diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index c8238c75643edc4cab74377548c85872f954fe90..d405a713edb39f6152d5135df35950aa0eb599a8 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -609,7 +609,7 @@ async def test_sensor_entity_total_liters( assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLUME + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER assert state.attributes.get(ATTR_ICON) == "mdi:gauge" diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 7a4202c1a674cb31f3e0d5544f33ff51ce6a0459..a4249a1efb618bc2d8cb79bdf480096909fb3f37 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -198,7 +198,17 @@ async def test_access_from_supervisor_ip( manager: IpBanManager = app[KEY_BAN_MANAGER] - assert await async_setup_component(hass, "hassio", {"hassio": {}}) + with patch( + "homeassistant.components.hassio.HassIO.get_resolution_info", + return_value={ + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + ): + assert await async_setup_component(hass, "hassio", {"hassio": {}}) m_open = mock_open() diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index 84f66e8f0ab61984eb788ca2dd1de3f8c8e68e3c..56c177a3602dd1abf8af5a07a8233c398539cd61 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import patch from huawei_lte_api.enums.client import ResponseCodeEnum from huawei_lte_api.enums.user import LoginErrorEnum, LoginStateEnum, PasswordTypeEnum import pytest +import requests.exceptions from requests.exceptions import ConnectionError from requests_mock import ANY @@ -119,27 +120,66 @@ def login_requests_mock(requests_mock): @pytest.mark.parametrize( - ("code", "errors"), + ("request_outcome", "fixture_override", "errors"), ( - (LoginErrorEnum.USERNAME_WRONG, {CONF_USERNAME: "incorrect_username"}), - (LoginErrorEnum.PASSWORD_WRONG, {CONF_PASSWORD: "incorrect_password"}), ( - LoginErrorEnum.USERNAME_PWD_WRONG, + { + "text": f"<error><code>{LoginErrorEnum.USERNAME_WRONG}</code><message/></error>", + }, + {}, + {CONF_USERNAME: "incorrect_username"}, + ), + ( + { + "text": f"<error><code>{LoginErrorEnum.PASSWORD_WRONG}</code><message/></error>", + }, + {}, + {CONF_PASSWORD: "incorrect_password"}, + ), + ( + { + "text": f"<error><code>{LoginErrorEnum.USERNAME_PWD_WRONG}</code><message/></error>", + }, + {}, {CONF_USERNAME: "invalid_auth"}, ), - (LoginErrorEnum.USERNAME_PWD_OVERRUN, {"base": "login_attempts_exceeded"}), - (ResponseCodeEnum.ERROR_SYSTEM_UNKNOWN, {"base": "response_error"}), + ( + { + "text": f"<error><code>{LoginErrorEnum.USERNAME_PWD_OVERRUN}</code><message/></error>", + }, + {}, + {"base": "login_attempts_exceeded"}, + ), + ( + { + "text": f"<error><code>{ResponseCodeEnum.ERROR_SYSTEM_UNKNOWN}</code><message/></error>", + }, + {}, + {"base": "response_error"}, + ), + ({}, {CONF_URL: "/foo/bar"}, {CONF_URL: "invalid_url"}), + ( + { + "exc": requests.exceptions.Timeout, + }, + {}, + {CONF_URL: "connection_timeout"}, + ), ), ) -async def test_login_error(hass, login_requests_mock, code, errors): +async def test_login_error( + hass, login_requests_mock, request_outcome, fixture_override, errors +): """Test we show user form with appropriate error on response failure.""" login_requests_mock.request( ANY, f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", - text=f"<error><code>{code}</code><message/></error>", + **request_outcome, ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={**FIXTURE_USER_INPUT, **fixture_override}, ) assert result["type"] == data_entry_flow.FlowResultType.FORM @@ -170,7 +210,43 @@ async def test_success(hass, login_requests_mock): assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] -async def test_ssdp(hass): +@pytest.mark.parametrize( + ("upnp_data", "expected_result"), + ( + ( + { + ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", + ssdp.ATTR_UPNP_SERIAL: "00000000", + }, + { + "type": data_entry_flow.FlowResultType.FORM, + "step_id": "user", + "errors": {}, + }, + ), + ( + { + ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", + # No ssdp.ATTR_UPNP_SERIAL + }, + { + "type": data_entry_flow.FlowResultType.FORM, + "step_id": "user", + "errors": {}, + }, + ), + ( + { + ssdp.ATTR_UPNP_FRIENDLY_NAME: "Some other device", + }, + { + "type": data_entry_flow.FlowResultType.ABORT, + "reason": "not_huawei_lte", + }, + ), + ), +) +async def test_ssdp(hass, upnp_data, expected_result): """Test SSDP discovery initiates config properly.""" url = "http://192.168.100.1/" context = {"source": config_entries.SOURCE_SSDP} @@ -183,21 +259,93 @@ async def test_ssdp(hass): ssdp_location="http://192.168.100.1:60957/rootDesc.xml", upnp={ ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", ssdp.ATTR_UPNP_MANUFACTURER: "Huawei", ssdp.ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", ssdp.ATTR_UPNP_MODEL_NAME: "Huawei router", ssdp.ATTR_UPNP_MODEL_NUMBER: "12345678", ssdp.ATTR_UPNP_PRESENTATION_URL: url, - ssdp.ATTR_UPNP_SERIAL: "00000000", ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + **upnp_data, }, ), ) + for k, v in expected_result.items(): + assert result[k] == v + if result.get("data_schema"): + result["data_schema"]({})[CONF_URL] == url + + +@pytest.mark.parametrize( + ("login_response_text", "expected_result", "expected_entry_data"), + ( + ( + "<response>OK</response>", + { + "type": data_entry_flow.FlowResultType.ABORT, + "reason": "reauth_successful", + }, + FIXTURE_USER_INPUT, + ), + ( + f"<error><code>{LoginErrorEnum.PASSWORD_WRONG}</code><message/></error>", + { + "type": data_entry_flow.FlowResultType.FORM, + "errors": {CONF_PASSWORD: "incorrect_password"}, + "step_id": "reauth_confirm", + }, + {**FIXTURE_USER_INPUT, CONF_PASSWORD: "invalid-password"}, + ), + ), +) +async def test_reauth( + hass, login_requests_mock, login_response_text, expected_result, expected_entry_data +): + """Test reauth.""" + mock_entry_data = {**FIXTURE_USER_INPUT, CONF_PASSWORD: "invalid-password"} + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FIXTURE_UNIQUE_ID, + data=mock_entry_data, + title="Reauth canary", + ) + entry.add_to_hass(hass) + + context = { + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context=context, data=entry.data + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["data_schema"]({})[CONF_URL] == url + assert result["step_id"] == "reauth_confirm" + assert result["data_schema"]({}) == { + CONF_USERNAME: mock_entry_data[CONF_USERNAME], + CONF_PASSWORD: mock_entry_data[CONF_PASSWORD], + } + assert not result["errors"] + + login_requests_mock.request( + ANY, + f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", + text=login_response_text, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: FIXTURE_USER_INPUT[CONF_USERNAME], + CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() + + for k, v in expected_result.items(): + assert result[k] == v + for k, v in expected_entry_data.items(): + assert entry.data[k] == v async def test_options(hass): diff --git a/tests/components/humidifier/test_recorder.py b/tests/components/humidifier/test_recorder.py index 28859e6133f9c349d126de45feaa99dea686d0ed..16f3b136180bdcae5492f7dea58fed4f7cfa46b2 100644 --- a/tests/components/humidifier/test_recorder.py +++ b/tests/components/humidifier/test_recorder.py @@ -20,7 +20,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test humidifier registered attributes to be excluded.""" await async_setup_component( hass, humidifier.DOMAIN, {humidifier.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/iaqualink/conftest.py b/tests/components/iaqualink/conftest.py index 6a46e063501bab0e3e114f7178bed28441c65c3b..b4db99dbe40ca1000061972938f7a10a0e94b11c 100644 --- a/tests/components/iaqualink/conftest.py +++ b/tests/components/iaqualink/conftest.py @@ -1,6 +1,6 @@ """Configuration for iAqualink tests.""" import random -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, PropertyMock, patch from iaqualink.client import AqualinkClient from iaqualink.device import AqualinkDevice @@ -47,14 +47,31 @@ def get_aqualink_system(aqualink, cls=None, data=None): return cls(aqualink=aqualink, data=data) -def get_aqualink_device(system, cls=None, data=None): +def get_aqualink_device(system, name, cls=None, data=None): """Create aqualink device.""" if cls is None: cls = AqualinkDevice + # AqualinkDevice doesn't implement some of the properties since it's left to + # sub-classes for them to do. Provide a basic implementation here for the + # benefits of the test suite. + attrs = { + "name": name, + "manufacturer": "Jandy", + "model": "Device", + "label": name.upper(), + } + + for k, v in attrs.items(): + patcher = patch.object(cls, k, new_callable=PropertyMock) + mock = patcher.start() + mock.return_value = v + if data is None: data = {} + data["name"] = name + return cls(system=system, data=data) @@ -72,7 +89,7 @@ def config_fixture(): @pytest.fixture(name="config_entry") def config_entry_fixture(): - """Create a mock HEOS config entry.""" + """Create a mock config entry.""" return MockConfigEntry( domain=DOMAIN, data=MOCK_DATA, diff --git a/tests/components/iaqualink/test_init.py b/tests/components/iaqualink/test_init.py index bd2e072d2136bfb9bbb77afcbb70854358c92505..3f2b822da81bd7aeec958a79983551a7d6803e16 100644 --- a/tests/components/iaqualink/test_init.py +++ b/tests/components/iaqualink/test_init.py @@ -4,15 +4,15 @@ import asyncio import logging from unittest.mock import AsyncMock, patch -from iaqualink.device import ( - AqualinkAuxToggle, - AqualinkBinarySensor, - AqualinkDevice, - AqualinkLightToggle, - AqualinkSensor, - AqualinkThermostat, -) from iaqualink.exception import AqualinkServiceException +from iaqualink.systems.iaqua.device import ( + IaquaAuxSwitch, + IaquaBinarySensor, + IaquaLightSwitch, + IaquaSensor, + IaquaThermostat, +) +from iaqualink.systems.iaqua.system import IaquaSystem from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN @@ -101,7 +101,7 @@ async def test_setup_devices_exception(hass, config_entry, client): """Test setup encountering an exception while retrieving devices.""" config_entry.add_to_hass(hass) - system = get_aqualink_system(client) + system = get_aqualink_system(client, cls=IaquaSystem) systems = {system.serial: system} with patch( @@ -124,10 +124,10 @@ async def test_setup_all_good_no_recognized_devices(hass, config_entry, client): """Test setup ending in no devices recognized.""" config_entry.add_to_hass(hass) - system = get_aqualink_system(client) + system = get_aqualink_system(client, cls=IaquaSystem) systems = {system.serial: system} - device = get_aqualink_device(system, AqualinkDevice, data={"name": "dev_1"}) + device = get_aqualink_device(system, name="dev_1") devices = {device.name: device} with patch( @@ -161,19 +161,15 @@ async def test_setup_all_good_all_device_types(hass, config_entry, client): """Test setup ending in one device of each type recognized.""" config_entry.add_to_hass(hass) - system = get_aqualink_system(client) + system = get_aqualink_system(client, cls=IaquaSystem) systems = {system.serial: system} devices = [ - get_aqualink_device(system, AqualinkAuxToggle, data={"name": "aux_1"}), - get_aqualink_device( - system, AqualinkBinarySensor, data={"name": "freeze_protection"} - ), - get_aqualink_device(system, AqualinkLightToggle, data={"name": "aux_2"}), - get_aqualink_device(system, AqualinkSensor, data={"name": "ph"}), - get_aqualink_device( - system, AqualinkThermostat, data={"name": "pool_set_point"} - ), + get_aqualink_device(system, name="aux_1", cls=IaquaAuxSwitch), + get_aqualink_device(system, name="freeze_protection", cls=IaquaBinarySensor), + get_aqualink_device(system, name="aux_2", cls=IaquaLightSwitch), + get_aqualink_device(system, name="ph", cls=IaquaSensor), + get_aqualink_device(system, name="pool_set_point", cls=IaquaThermostat), ] devices = {d.name: d for d in devices} @@ -207,7 +203,7 @@ async def test_multiple_updates(hass, config_entry, caplog, client): """Test all possible results of online status transition after update.""" config_entry.add_to_hass(hass) - system = get_aqualink_system(client) + system = get_aqualink_system(client, cls=IaquaSystem) systems = {system.serial: system} system.get_devices = AsyncMock(return_value={}) @@ -269,7 +265,7 @@ async def test_multiple_updates(hass, config_entry, caplog, client): system.update.side_effect = set_online_to_true await _ffwd_next_update_interval(hass) assert len(caplog.records) == 1 - assert "Reconnected" in caplog.text + assert "reconnected" in caplog.text # False -> None / ServiceException system.online = False @@ -292,7 +288,7 @@ async def test_multiple_updates(hass, config_entry, caplog, client): system.update.side_effect = set_online_to_true await _ffwd_next_update_interval(hass) assert len(caplog.records) == 1 - assert "Reconnected" in caplog.text + assert "reconnected" in caplog.text # None -> False system.online = None @@ -311,11 +307,11 @@ async def test_entity_assumed_and_available(hass, config_entry, client): """Test assumed_state and_available properties for all values of online.""" config_entry.add_to_hass(hass) - system = get_aqualink_system(client) + system = get_aqualink_system(client, cls=IaquaSystem) systems = {system.serial: system} light = get_aqualink_device( - system, AqualinkLightToggle, data={"name": "aux_1", "state": "1"} + system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} ) devices = {d.name: d for d in [light]} system.get_devices = AsyncMock(return_value=devices) diff --git a/tests/components/ibeacon/__init__.py b/tests/components/ibeacon/__init__.py index f10bc65ed337ce43c13ab9844fcb60a08be75339..56d5eb784670cbfcb9932e8c6b6f821bcb6a0401 100644 --- a/tests/components/ibeacon/__init__.py +++ b/tests/components/ibeacon/__init__.py @@ -58,7 +58,21 @@ BEACON_RANDOM_ADDRESS_SERVICE_INFO = BluetoothServiceInfo( service_uuids=[], source="local", ) - +TESLA_TRANSIENT = BluetoothServiceInfo( + address="CC:CC:CC:CC:CC:CC", + rssi=-60, + name="S6da7c9389bd5452cC", + manufacturer_data={ + 76: b"\x02\x15t'\x8b\xda\xb6DE \x8f\x0cr\x0e\xaf\x05\x995\x00\x00[$\xc5" + }, + service_data={}, + service_uuids=[], + source="hci0", +) +TESLA_TRANSIENT_BLE_DEVICE = BLEDevice( + address="CC:CC:CC:CC:CC:CC", + name="S6da7c9389bd5452cC", +) FEASY_BEACON_BLE_DEVICE = BLEDevice( address="AA:BB:CC:DD:EE:FF", diff --git a/tests/components/ibeacon/test_coordinator.py b/tests/components/ibeacon/test_coordinator.py index 5ea19914ee45df15b7b28b2640948e36b844d1f3..25ce7154a378163c8660af7fc7c080a9998fd498 100644 --- a/tests/components/ibeacon/test_coordinator.py +++ b/tests/components/ibeacon/test_coordinator.py @@ -2,16 +2,27 @@ from dataclasses import replace +from datetime import timedelta import pytest -from homeassistant.components.ibeacon.const import DOMAIN +from homeassistant.components.ibeacon.const import DOMAIN, UPDATE_INTERVAL +from homeassistant.const import STATE_HOME from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from homeassistant.util import dt as dt_util -from . import BLUECHARM_BEACON_SERVICE_INFO, BLUECHARM_BEACON_SERVICE_INFO_DBUS +from . import ( + BLUECHARM_BEACON_SERVICE_INFO, + BLUECHARM_BEACON_SERVICE_INFO_DBUS, + TESLA_TRANSIENT, + TESLA_TRANSIENT_BLE_DEVICE, +) -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, +) @pytest.fixture(autouse=True) @@ -195,3 +206,49 @@ async def test_rotating_major_minor_and_mac_no_name(hass): await hass.async_block_till_done() assert len(hass.states.async_entity_ids("device_tracker")) == before_entity_count + + +async def test_ignore_transient_devices_unless_we_see_them_a_few_times(hass): + """Test we ignore transient devices unless we see them a few times.""" + entry = MockConfigEntry( + domain=DOMAIN, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + before_entity_count = len(hass.states.async_entity_ids()) + inject_bluetooth_service_info( + hass, + TESLA_TRANSIENT, + ) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == before_entity_count + + with patch_all_discovered_devices([TESLA_TRANSIENT_BLE_DEVICE]): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=UPDATE_INTERVAL.total_seconds() * 2), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == before_entity_count + + for i in range(3, 17): + with patch_all_discovered_devices([TESLA_TRANSIENT_BLE_DEVICE]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=UPDATE_INTERVAL.total_seconds() * 2 * i), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) > before_entity_count + + assert hass.states.get("device_tracker.s6da7c9389bd5452cc_cccc").state == STATE_HOME + + await hass.config_entries.async_reload(entry.entry_id) + + await hass.async_block_till_done() + assert hass.states.get("device_tracker.s6da7c9389bd5452cc_cccc").state == STATE_HOME diff --git a/tests/components/input_boolean/test_recorder.py b/tests/components/input_boolean/test_recorder.py index e7f68379343f61c8838bb213e5ae98e58010c23a..685738669650534ec0453a8e6e9e734263e5d9e8 100644 --- a/tests/components/input_boolean/test_recorder.py +++ b/tests/components/input_boolean/test_recorder.py @@ -16,7 +16,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test attributes to be excluded.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) diff --git a/tests/components/input_button/test_recorder.py b/tests/components/input_button/test_recorder.py index e469536549a70104ca0dd03589989ec3ef84eff1..53b2b9615f6ea7a1d13ebbbfedc1e5065573e139 100644 --- a/tests/components/input_button/test_recorder.py +++ b/tests/components/input_button/test_recorder.py @@ -16,7 +16,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test attributes to be excluded.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) diff --git a/tests/components/input_datetime/test_recorder.py b/tests/components/input_datetime/test_recorder.py index bbdd0446e563f88baf75817465691a1ba8a9f01e..d5c95d5951f25bd0db158d6ac1ea4d9423c2d7dc 100644 --- a/tests/components/input_datetime/test_recorder.py +++ b/tests/components/input_datetime/test_recorder.py @@ -16,7 +16,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test attributes to be excluded.""" assert await async_setup_component( diff --git a/tests/components/input_number/test_recorder.py b/tests/components/input_number/test_recorder.py index f736d450e7abb7fcaf2575338ff93dd8b19da9aa..325234e069b44265ba516454702f55ff12de42fc 100644 --- a/tests/components/input_number/test_recorder.py +++ b/tests/components/input_number/test_recorder.py @@ -22,7 +22,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test attributes to be excluded.""" assert await async_setup_component( diff --git a/tests/components/input_select/test_recorder.py b/tests/components/input_select/test_recorder.py index 2931132bafc340f07e4cbc9aef2ffc1eeb7fea8d..457fc0feccc467b740977677898d5e99a33a30ab 100644 --- a/tests/components/input_select/test_recorder.py +++ b/tests/components/input_select/test_recorder.py @@ -16,7 +16,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test attributes to be excluded.""" assert await async_setup_component( diff --git a/tests/components/input_text/test_recorder.py b/tests/components/input_text/test_recorder.py index 928399cd93953ca09f04e328fc9e4ce0ae3db4a6..9557f6564650350396820a4169cc809a53bfa0c7 100644 --- a/tests/components/input_text/test_recorder.py +++ b/tests/components/input_text/test_recorder.py @@ -23,7 +23,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test attributes to be excluded.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py index 35099c405bb97ef49887a3346d6a012a27d2e6e7..4a002140437be901e720de5b1d0b4cdbd039632e 100644 --- a/tests/components/ipma/__init__.py +++ b/tests/components/ipma/__init__.py @@ -1 +1,111 @@ """Tests for the IPMA component.""" +from collections import namedtuple +from datetime import datetime, timezone + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME + +ENTRY_CONFIG = { + CONF_NAME: "Home Town", + CONF_LATITUDE: "1", + CONF_LONGITUDE: "2", + CONF_MODE: "hourly", +} + + +class MockLocation: + """Mock Location from pyipma.""" + + async def observation(self, api): + """Mock Observation.""" + Observation = namedtuple( + "Observation", + [ + "accumulated_precipitation", + "humidity", + "pressure", + "radiation", + "temperature", + "wind_direction", + "wind_intensity_km", + ], + ) + + return Observation(0.0, 71.0, 1000.0, 0.0, 18.0, "NW", 3.94) + + async def forecast(self, api, period): + """Mock Forecast.""" + Forecast = namedtuple( + "Forecast", + [ + "feels_like_temperature", + "forecast_date", + "forecasted_hours", + "humidity", + "max_temperature", + "min_temperature", + "precipitation_probability", + "temperature", + "update_date", + "weather_type", + "wind_direction", + "wind_strength", + ], + ) + + WeatherType = namedtuple("WeatherType", ["id", "en", "pt"]) + + if period == 24: + return [ + Forecast( + None, + datetime(2020, 1, 16, 0, 0, 0), + 24, + None, + 16.2, + 10.6, + "100.0", + 13.4, + "2020-01-15T07:51:00", + WeatherType(9, "Rain/showers", "Chuva/aguaceiros"), + "S", + "10", + ), + ] + if period == 1: + return [ + Forecast( + "7.7", + datetime(2020, 1, 15, 1, 0, 0, tzinfo=timezone.utc), + 1, + "86.9", + 12.0, + None, + 80.0, + 10.6, + "2020-01-15T02:51:00", + WeatherType(10, "Light rain", "Chuva fraca ou chuvisco"), + "S", + "32.7", + ), + Forecast( + "5.7", + datetime(2020, 1, 15, 2, 0, 0, tzinfo=timezone.utc), + 1, + "86.9", + 12.0, + None, + 80.0, + 10.6, + "2020-01-15T02:51:00", + WeatherType(1, "Clear sky", "C\u00e9u limpo"), + "S", + "32.7", + ), + ] + + name = "HomeTown" + station = "HomeTown Station" + station_latitude = 0 + station_longitude = 0 + global_id_local = 1130600 + id_station = 1200545 diff --git a/tests/components/ipma/test_config_flow.py b/tests/components/ipma/test_config_flow.py index ea4b0b510e732615f3945a3ecc8822c3d91f3f6a..e254ba402fb96e1a9df3f4074404767d542de61e 100644 --- a/tests/components/ipma/test_config_flow.py +++ b/tests/components/ipma/test_config_flow.py @@ -3,21 +3,14 @@ from unittest.mock import Mock, patch from homeassistant.components.ipma import DOMAIN, config_flow -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .test_weather import MockLocation +from . import MockLocation from tests.common import MockConfigEntry, mock_registry -ENTRY_CONFIG = { - CONF_NAME: "Home Town", - CONF_LATITUDE: "1", - CONF_LONGITUDE: "2", - CONF_MODE: "hourly", -} - async def test_show_config_form(): """Test show configuration form.""" diff --git a/tests/components/ipma/test_init.py b/tests/components/ipma/test_init.py new file mode 100644 index 0000000000000000000000000000000000000000..8dd808b1b1baa6ce8047921994871734ce78cd7d --- /dev/null +++ b/tests/components/ipma/test_init.py @@ -0,0 +1,57 @@ +"""Test the IPMA integration.""" + +from unittest.mock import patch + +from pyipma import IPMAException + +from homeassistant.components.ipma import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE + +from .test_weather import MockLocation + +from tests.common import MockConfigEntry + + +async def test_async_setup_raises_entry_not_ready(hass): + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + + with patch( + "pyipma.location.Location.get", side_effect=IPMAException("API unavailable") + ): + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + data={CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_MODE: "daily"}, + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_config_entry(hass): + """Test entry unloading.""" + + with patch( + "pyipma.location.Location.get", + return_value=MockLocation(), + ): + config_entry = MockConfigEntry( + domain="ipma", + data={CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_MODE: "daily"}, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py index e129216730d86973f99295c47230b3ede657219a..62450871ee8fbf0722c22216437527b336251c7c 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -1,6 +1,5 @@ """The tests for the IPMA weather component.""" -from collections import namedtuple -from datetime import datetime, timezone +from datetime import datetime from unittest.mock import patch from freezegun import freeze_time @@ -22,6 +21,8 @@ from homeassistant.components.weather import ( ) from homeassistant.const import STATE_UNKNOWN +from . import MockLocation + from tests.common import MockConfigEntry TEST_CONFIG = { @@ -39,128 +40,6 @@ TEST_CONFIG_HOURLY = { } -class MockLocation: - """Mock Location from pyipma.""" - - async def observation(self, api): - """Mock Observation.""" - Observation = namedtuple( - "Observation", - [ - "accumulated_precipitation", - "humidity", - "pressure", - "radiation", - "temperature", - "wind_direction", - "wind_intensity_km", - ], - ) - - return Observation(0.0, 71.0, 1000.0, 0.0, 18.0, "NW", 3.94) - - async def forecast(self, api, period): - """Mock Forecast.""" - Forecast = namedtuple( - "Forecast", - [ - "feels_like_temperature", - "forecast_date", - "forecasted_hours", - "humidity", - "max_temperature", - "min_temperature", - "precipitation_probability", - "temperature", - "update_date", - "weather_type", - "wind_direction", - "wind_strength", - ], - ) - - WeatherType = namedtuple("WeatherType", ["id", "en", "pt"]) - - if period == 24: - return [ - Forecast( - None, - datetime(2020, 1, 16, 0, 0, 0), - 24, - None, - 16.2, - 10.6, - "100.0", - 13.4, - "2020-01-15T07:51:00", - WeatherType(9, "Rain/showers", "Chuva/aguaceiros"), - "S", - "10", - ), - ] - if period == 1: - return [ - Forecast( - "7.7", - datetime(2020, 1, 15, 1, 0, 0, tzinfo=timezone.utc), - 1, - "86.9", - 12.0, - None, - 80.0, - 10.6, - "2020-01-15T02:51:00", - WeatherType(10, "Light rain", "Chuva fraca ou chuvisco"), - "S", - "32.7", - ), - Forecast( - "5.7", - datetime(2020, 1, 15, 2, 0, 0, tzinfo=timezone.utc), - 1, - "86.9", - 12.0, - None, - 80.0, - 10.6, - "2020-01-15T02:51:00", - WeatherType(1, "Clear sky", "C\u00e9u limpo"), - "S", - "32.7", - ), - ] - - @property - def name(self): - """Mock location.""" - return "HomeTown" - - @property - def station(self): - """Mock station.""" - return "HomeTown Station" - - @property - def station_latitude(self): - """Mock latitude.""" - return 0 - - @property - def global_id_local(self): - """Mock global identifier of the location.""" - return 1130600 - - @property - def id_station(self): - """Mock identifier of the station.""" - return 1200545 - - @property - def station_longitude(self): - """Mock longitude.""" - return 0 - - class MockBadLocation(MockLocation): """Mock Location with unresponsive api.""" diff --git a/tests/components/iqvia/conftest.py b/tests/components/iqvia/conftest.py index 5b6a76e7e5723ca35b6120f4ef9f7ca23b27e9f9..b6ac172488552e2550ef7752da405c2d79d239ea 100644 --- a/tests/components/iqvia/conftest.py +++ b/tests/components/iqvia/conftest.py @@ -11,9 +11,9 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, unique_id): +def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_ZIP_CODE], data=config) entry.add_to_hass(hass) return entry @@ -26,43 +26,43 @@ def config_fixture(hass): } -@pytest.fixture(name="data_allergy_forecast", scope="session") +@pytest.fixture(name="data_allergy_forecast", scope="package") def data_allergy_forecast_fixture(): """Define allergy forecast data.""" return json.loads(load_fixture("allergy_forecast_data.json", "iqvia")) -@pytest.fixture(name="data_allergy_index", scope="session") +@pytest.fixture(name="data_allergy_index", scope="package") def data_allergy_index_fixture(): """Define allergy index data.""" return json.loads(load_fixture("allergy_index_data.json", "iqvia")) -@pytest.fixture(name="data_allergy_outlook", scope="session") +@pytest.fixture(name="data_allergy_outlook", scope="package") def data_allergy_outlook_fixture(): """Define allergy outlook data.""" return json.loads(load_fixture("allergy_outlook_data.json", "iqvia")) -@pytest.fixture(name="data_asthma_forecast", scope="session") +@pytest.fixture(name="data_asthma_forecast", scope="package") def data_asthma_forecast_fixture(): """Define asthma forecast data.""" return json.loads(load_fixture("asthma_forecast_data.json", "iqvia")) -@pytest.fixture(name="data_asthma_index", scope="session") +@pytest.fixture(name="data_asthma_index", scope="package") def data_asthma_index_fixture(): """Define asthma index data.""" return json.loads(load_fixture("asthma_index_data.json", "iqvia")) -@pytest.fixture(name="data_disease_forecast", scope="session") +@pytest.fixture(name="data_disease_forecast", scope="package") def data_disease_forecast_fixture(): """Define disease forecast data.""" return json.loads(load_fixture("disease_forecast_data.json", "iqvia")) -@pytest.fixture(name="data_disease_index", scope="session") +@pytest.fixture(name="data_disease_index", scope="package") def data_disease_index_fixture(): """Define disease index data.""" return json.loads(load_fixture("disease_index_data.json", "iqvia")) @@ -101,9 +101,3 @@ async def setup_iqvia_fixture( assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "12345" diff --git a/tests/components/iqvia/test_diagnostics.py b/tests/components/iqvia/test_diagnostics.py index 4c5f4bcac75ef8eb28ee8da48bca6b509077b20d..ee3e6817fc15bbc5fd9b8279b5f6d73447581bd3 100644 --- a/tests/components/iqvia/test_diagnostics.py +++ b/tests/components/iqvia/test_diagnostics.py @@ -1,4 +1,6 @@ """Test IQVIA diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -6,19 +8,26 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { - "title": "Mock Title", - "data": { - "zip_code": "12345", - }, + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "iqvia", + "title": REDACTED, + "data": {"zip_code": REDACTED}, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { "allergy_average_forecasted": { "Type": "pollen", "ForecastDate": "2018-06-12T00:00:00-04:00", "Location": { - "ZIP": "12345", - "City": "SCHENECTADY", - "State": "NY", + "ZIP": REDACTED, + "City": REDACTED, + "State": REDACTED, "periods": [ {"Period": "2018-06-12T13:47:12.897", "Index": 6.6}, {"Period": "2018-06-13T13:47:12.897", "Index": 6.3}, @@ -26,16 +35,16 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): {"Period": "2018-06-15T13:47:12.897", "Index": 7.6}, {"Period": "2018-06-16T13:47:12.897", "Index": 7.3}, ], - "DisplayLocation": "Schenectady, NY", + "DisplayLocation": REDACTED, }, }, "allergy_index": { "Type": "pollen", "ForecastDate": "2018-06-12T00:00:00-04:00", "Location": { - "ZIP": "12345", - "City": "SCHENECTADY", - "State": "NY", + "ZIP": REDACTED, + "City": REDACTED, + "State": REDACTED, "periods": [ { "Triggers": [ @@ -113,12 +122,12 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): "Index": 6.3, }, ], - "DisplayLocation": "Schenectady, NY", + "DisplayLocation": REDACTED, }, }, "allergy_outlook": { - "Market": "SCHENECTADY, CO", - "ZIP": "12345", + "Market": REDACTED, + "ZIP": REDACTED, "TrendID": 4, "Trend": "subsiding", "Outlook": "The amount of pollen in the air for Wednesday...", @@ -128,9 +137,9 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): "Type": "asthma", "ForecastDate": "2018-10-28T00:00:00-04:00", "Location": { - "ZIP": "12345", - "City": "SCHENECTADY", - "State": "NY", + "ZIP": REDACTED, + "City": REDACTED, + "State": REDACTED, "periods": [ { "Period": "2018-10-28T05:45:01.45", @@ -154,16 +163,16 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): "Idx": "5.5", }, ], - "DisplayLocation": "Schenectady, NY", + "DisplayLocation": REDACTED, }, }, "asthma_index": { "Type": "asthma", "ForecastDate": "2018-10-29T00:00:00-04:00", "Location": { - "ZIP": "12345", - "City": "SCHENECTADY", - "State": "NY", + "ZIP": REDACTED, + "City": REDACTED, + "State": REDACTED, "periods": [ { "Triggers": [ @@ -225,32 +234,32 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): "Idx": "4.6", }, ], - "DisplayLocation": "Schenectady, NY", + "DisplayLocation": REDACTED, }, }, "disease_average_forecasted": { "Type": "cold", "ForecastDate": "2018-06-12T00:00:00-04:00", "Location": { - "ZIP": "12345", - "City": "SCHENECTADY", - "State": "NY", + "ZIP": REDACTED, + "City": REDACTED, + "State": REDACTED, "periods": [ {"Period": "2018-06-12T05:13:51.817", "Index": 2.4}, {"Period": "2018-06-13T05:13:51.817", "Index": 2.5}, {"Period": "2018-06-14T05:13:51.817", "Index": 2.5}, {"Period": "2018-06-15T05:13:51.817", "Index": 2.5}, ], - "DisplayLocation": "Schenectady, NY", + "DisplayLocation": REDACTED, }, }, "disease_index": { "ForecastDate": "2019-04-07T00:00:00-04:00", "Location": { - "City": "SCHENECTADY", - "DisplayLocation": "Schenectady, NY", - "State": "NY", - "ZIP": "12345", + "City": REDACTED, + "DisplayLocation": REDACTED, + "State": REDACTED, + "ZIP": REDACTED, "periods": [ { "Idx": "6.8", diff --git a/tests/components/jellyfin/__init__.py b/tests/components/jellyfin/__init__.py index e5ff9ab32073d15d8e95a414ef5783ba9375d3ea..c1f7bbb2f35ae2187e541477c9a94b8404329cd1 100644 --- a/tests/components/jellyfin/__init__.py +++ b/tests/components/jellyfin/__init__.py @@ -1 +1,17 @@ """Tests for the jellyfin integration.""" +import json +from typing import Any + +from homeassistant.core import HomeAssistant + +from tests.common import load_fixture + + +def load_json_fixture(filename: str) -> Any: + """Load JSON fixture on-demand.""" + return json.loads(load_fixture(f"jellyfin/{filename}")) + + +async def async_load_json_fixture(hass: HomeAssistant, filename: str) -> Any: + """Load JSON fixture on-demand asynchronously.""" + return await hass.async_add_executor_job(load_json_fixture, filename) diff --git a/tests/components/jellyfin/conftest.py b/tests/components/jellyfin/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..423e4ad395069b086d49caa6ac378af5936a31e5 --- /dev/null +++ b/tests/components/jellyfin/conftest.py @@ -0,0 +1,153 @@ +"""Fixtures for Jellyfin integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch + +from jellyfin_apiclient_python import JellyfinClient +from jellyfin_apiclient_python.api import API +from jellyfin_apiclient_python.configuration import Config +from jellyfin_apiclient_python.connection_manager import ConnectionManager +import pytest + +from homeassistant.components.jellyfin.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import load_json_fixture +from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Jellyfin", + domain=DOMAIN, + data={ + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + unique_id="USER-UUID", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.jellyfin.async_setup_entry", return_value=True + ) as setup_mock: + yield setup_mock + + +@pytest.fixture +def mock_client_device_id() -> Generator[None, MagicMock, None]: + """Mock generating device id.""" + with patch( + "homeassistant.components.jellyfin.config_flow._generate_client_device_id" + ) as id_mock: + id_mock.return_value = "TEST-UUID" + yield id_mock + + +@pytest.fixture +def mock_auth() -> MagicMock: + """Return a mocked ConnectionManager.""" + jf_auth = create_autospec(ConnectionManager) + jf_auth.connect_to_address.return_value = load_json_fixture( + "auth-connect-address.json" + ) + jf_auth.login.return_value = load_json_fixture("auth-login.json") + + return jf_auth + + +@pytest.fixture +def mock_api() -> MagicMock: + """Return a mocked API.""" + jf_api = create_autospec(API) + jf_api.get_user_settings.return_value = load_json_fixture("get-user-settings.json") + jf_api.sessions.return_value = load_json_fixture("sessions.json") + + jf_api.artwork.side_effect = api_artwork_side_effect + jf_api.user_items.side_effect = api_user_items_side_effect + jf_api.get_item.side_effect = api_get_item_side_effect + jf_api.get_media_folders.return_value = load_json_fixture("get-media-folders.json") + jf_api.user_items.side_effect = api_user_items_side_effect + + return jf_api + + +@pytest.fixture +def mock_config() -> MagicMock: + """Return a mocked JellyfinClient.""" + jf_config = create_autospec(Config) + jf_config.data = {} + + return jf_config + + +@pytest.fixture +def mock_client( + mock_config: MagicMock, mock_auth: MagicMock, mock_api: MagicMock +) -> MagicMock: + """Return a mocked JellyfinClient.""" + jf_client = create_autospec(JellyfinClient) + jf_client.auth = mock_auth + jf_client.config = mock_config + jf_client.jellyfin = mock_api + + return jf_client + + +@pytest.fixture +def mock_jellyfin(mock_client: MagicMock) -> Generator[None, MagicMock, None]: + """Return a mocked Jellyfin.""" + with patch( + "homeassistant.components.jellyfin.client_wrapper.Jellyfin", autospec=True + ) as jellyfin_mock: + jf = jellyfin_mock.return_value + jf.get_client.return_value = mock_client + + yield jf + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_jellyfin: MagicMock +) -> MockConfigEntry: + """Set up the Jellyfin integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +def api_artwork_side_effect(*args, **kwargs): + """Handle variable responses for artwork method.""" + item_id = args[0] + art = args[1] + ext = "jpg" + + return f"http://localhost/Items/{item_id}/Images/{art}.{ext}" + + +def api_get_item_side_effect(*args): + """Handle variable responses for get_item method.""" + return load_json_fixture("get-item-collection.json") + + +def api_user_items_side_effect(*args, **kwargs): + """Handle variable responses for items method.""" + params = kwargs.get("params", {}) if kwargs else {} + + if "parentId" in params: + return load_json_fixture("user-items-parent-id.json") + + return load_json_fixture("user-items.json") diff --git a/tests/components/jellyfin/const.py b/tests/components/jellyfin/const.py index b33f00818b733f056f6f2336e9dfd9652c3b1327..4953824a1c536f2f0ecdc309f6b9b381a6268592 100644 --- a/tests/components/jellyfin/const.py +++ b/tests/components/jellyfin/const.py @@ -2,16 +2,6 @@ from typing import Final -from jellyfin_apiclient_python.connection_manager import CONNECTION_STATE - TEST_URL: Final = "https://example.com" TEST_USERNAME: Final = "test-username" TEST_PASSWORD: Final = "test-password" - -MOCK_SUCCESFUL_CONNECTION_STATE: Final = {"State": CONNECTION_STATE["ServerSignIn"]} -MOCK_SUCCESFUL_LOGIN_RESPONSE: Final = {"AccessToken": "Test"} - -MOCK_UNSUCCESFUL_CONNECTION_STATE: Final = {"State": CONNECTION_STATE["Unavailable"]} -MOCK_UNSUCCESFUL_LOGIN_RESPONSE: Final = {""} - -MOCK_USER_SETTINGS: Final = {"Id": "123"} diff --git a/tests/components/jellyfin/fixtures/auth-connect-address-failure.json b/tests/components/jellyfin/fixtures/auth-connect-address-failure.json new file mode 100644 index 0000000000000000000000000000000000000000..9055c2c7105d586887c35408a6c61edd9f184922 --- /dev/null +++ b/tests/components/jellyfin/fixtures/auth-connect-address-failure.json @@ -0,0 +1,3 @@ +{ + "State": 0 +} diff --git a/tests/components/jellyfin/fixtures/auth-connect-address.json b/tests/components/jellyfin/fixtures/auth-connect-address.json new file mode 100644 index 0000000000000000000000000000000000000000..2adfded30708c31690b1480334f53d9969dcb9ee --- /dev/null +++ b/tests/components/jellyfin/fixtures/auth-connect-address.json @@ -0,0 +1,4 @@ +{ + "State": 2, + "Servers": [{ "Id": "SERVER-UUID", "Name": "JELLYFIN-SERVER" }] +} diff --git a/tests/components/jellyfin/fixtures/auth-login-failure.json b/tests/components/jellyfin/fixtures/auth-login-failure.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/tests/components/jellyfin/fixtures/auth-login-failure.json @@ -0,0 +1 @@ +{} diff --git a/tests/components/jellyfin/fixtures/auth-login.json b/tests/components/jellyfin/fixtures/auth-login.json new file mode 100644 index 0000000000000000000000000000000000000000..5df9dd599a8e431de273343edd615ebf9ac11186 --- /dev/null +++ b/tests/components/jellyfin/fixtures/auth-login.json @@ -0,0 +1,1844 @@ +{ + "User": { + "Name": "string", + "ServerId": "string", + "ServerName": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PrimaryImageTag": "string", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": true, + "EnableAutoLogin": true, + "LastLoginDate": "2019-08-24T14:15:22Z", + "LastActivityDate": "2019-08-24T14:15:22Z", + "Configuration": { + "AudioLanguagePreference": "string", + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "string", + "DisplayMissingEpisodes": true, + "GroupedFolders": ["string"], + "SubtitleMode": "Default", + "DisplayCollectionsView": true, + "EnableLocalPassword": true, + "OrderedViews": ["string"], + "LatestItemsExcludes": ["string"], + "MyMediaExcludes": ["string"], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true + }, + "Policy": { + "IsAdministrator": true, + "IsHidden": true, + "IsDisabled": true, + "MaxParentalRating": 0, + "BlockedTags": ["string"], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [ + { + "Id": 0, + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "DayOfWeek": "Sunday", + "StartHour": 0, + "EndHour": 0 + } + ], + "BlockUnratedItems": ["Movie"], + "EnableRemoteControlOfOtherUsers": true, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": true, + "EnableLiveTvAccess": true, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": true, + "EnableContentDeletion": true, + "EnableContentDeletionFromFolders": ["string"], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": ["string"], + "EnableAllDevices": true, + "EnabledChannels": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "EnableAllChannels": true, + "EnabledFolders": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "EnableAllFolders": true, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": 0, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "BlockedChannels": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "string", + "PasswordResetProviderId": "string", + "SyncPlayAccess": "CreateAndJoinGroups" + }, + "PrimaryImageAspectRatio": 0 + }, + "SessionInfo": { + "PlayState": { + "PositionTicks": 0, + "CanSeek": true, + "IsPaused": true, + "IsMuted": true, + "VolumeLevel": 0, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["string"], + "SupportedCommands": ["MoveUp"], + "SupportsMediaControl": true, + "SupportsContentUploading": true, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": true, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["string"], + "Id": "string", + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string", + "Client": "string", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "string", + "DeviceType": "string", + "NowPlayingItem": { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "FullNowPlayingItem": { + "Size": 0, + "Container": "string", + "IsHD": true, + "IsShortcut": true, + "ShortcutPath": "string", + "Width": 0, + "Height": 0, + "ExtraIds": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "DateLastSaved": "2019-08-24T14:15:22Z", + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "SupportsExternalTransfer": true + }, + "NowViewingItem": { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "DeviceId": "string", + "ApplicationVersion": "string", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "string", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + "AccessToken": "string", + "ServerId": "string" +} diff --git a/tests/components/jellyfin/fixtures/get-item-collection.json b/tests/components/jellyfin/fixtures/get-item-collection.json new file mode 100644 index 0000000000000000000000000000000000000000..90ad63a39e4ce01e5e865eafca3a930a709b6b54 --- /dev/null +++ b/tests/components/jellyfin/fixtures/get-item-collection.json @@ -0,0 +1,504 @@ +{ + "Name": "FOLDER", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "FOLDER-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "CollectionFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} +} diff --git a/tests/components/jellyfin/fixtures/get-media-folders.json b/tests/components/jellyfin/fixtures/get-media-folders.json new file mode 100644 index 0000000000000000000000000000000000000000..ff87751a9daa17ccbbff833aa7216916c0d06539 --- /dev/null +++ b/tests/components/jellyfin/fixtures/get-media-folders.json @@ -0,0 +1,510 @@ +{ + "Items": [ + { + "Name": "COLLECTION FOLDER", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "COLLECTION-FOLDER-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "CollectionFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "SERIES-UUID", + "SeasonId": "SEASON-UUID", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "tvshows", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "TotalRecordCount": 0, + "StartIndex": 0 +} diff --git a/tests/components/jellyfin/fixtures/get-user-settings.json b/tests/components/jellyfin/fixtures/get-user-settings.json new file mode 100644 index 0000000000000000000000000000000000000000..5e28f87d8f208e714311b8fd8a20948cd163977b --- /dev/null +++ b/tests/components/jellyfin/fixtures/get-user-settings.json @@ -0,0 +1,19 @@ +{ + "Id": "string", + "ViewType": "string", + "SortBy": "string", + "IndexBy": "string", + "RememberIndexing": true, + "PrimaryImageHeight": 0, + "PrimaryImageWidth": 0, + "CustomPrefs": { + "property1": "string", + "property2": "string" + }, + "ScrollDirection": "Horizontal", + "ShowBackdrop": true, + "RememberSorting": true, + "SortOrder": "Ascending", + "ShowSidebar": true, + "Client": "emby" +} diff --git a/tests/components/jellyfin/fixtures/sessions.json b/tests/components/jellyfin/fixtures/sessions.json new file mode 100644 index 0000000000000000000000000000000000000000..00a1f5265db641464a6e6fb4424c381336c22894 --- /dev/null +++ b/tests/components/jellyfin/fixtures/sessions.json @@ -0,0 +1,4551 @@ +[ + { + "PlayState": { + "PositionTicks": 100000000, + "CanSeek": true, + "IsPaused": true, + "IsMuted": true, + "VolumeLevel": 0, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["VolumeSet", "Mute"], + "SupportsMediaControl": true, + "SupportsContentUploading": true, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": true, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["Video"], + "Id": "SESSION-UUID", + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string", + "Client": "Jellyfin for Developers", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "JELLYFIN-DEVICE", + "DeviceType": "string", + "NowPlayingItem": { + "Name": "EPISODE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 600000000, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 3, + "IndexNumberEnd": 0, + "ParentIndexNumber": 1, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "PARENT-UUID", + "Type": "Episode", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "SERIES", + "SeriesId": "SERIES-UUID", + "SeasonId": "SEASON-UUID", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "SEASON", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "HASS", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "FullNowPlayingItem": { + "Size": 0, + "Container": "string", + "IsHD": true, + "IsShortcut": true, + "ShortcutPath": "string", + "Width": 0, + "Height": 0, + "ExtraIds": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "DateLastSaved": "2019-08-24T14:15:22Z", + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "SupportsExternalTransfer": true + }, + "NowViewingItem": { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "DeviceId": "DEVICE-UUID", + "ApplicationVersion": "1.0.0", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "SERVER-UUID", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + { + "PlayState": { + "PositionTicks": 230000000, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + "VolumeLevel": 55, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["VolumeSet", "Mute"], + "SupportsMediaControl": true, + "SupportsContentUploading": true, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": true, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["Video"], + "Id": "SESSION-UUID-TWO", + "UserId": "USER-UUID-TWO", + "UserName": "string", + "Client": "Jellyfin for Developers", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "JELLYFIN-DEVICE-TWO", + "DeviceType": "string", + "NowPlayingItem": { + "Name": "MOVIE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 2000000000, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "", + "Type": "Movie", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "SERIES", + "SeriesId": "SERIES-UUID", + "SeasonId": "SEASON-UUID", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "SEASON", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "Backdrop": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "HASS", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "FullNowPlayingItem": { + "Size": 0, + "Container": "string", + "IsHD": true, + "IsShortcut": true, + "ShortcutPath": "string", + "Width": 0, + "Height": 0, + "ExtraIds": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "DateLastSaved": "2019-08-24T14:15:22Z", + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "SupportsExternalTransfer": true + }, + "NowViewingItem": { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "DeviceId": "DEVICE-UUID-TWO", + "ApplicationVersion": "1.0.0", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "SERVER-UUID", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + { + "PlayState": { + "PositionTicks": 0, + "CanSeek": true, + "IsPaused": false, + "IsMuted": true, + "VolumeLevel": 0, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["MoveUp"], + "SupportsMediaControl": false, + "SupportsContentUploading": false, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": false, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["Video"], + "Id": "SESSION-UUID-THREE", + "UserId": "USER-UUID", + "UserName": "string", + "Client": "Jellyfin for Developers", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "JELLYFIN-DEVICE-THREE", + "DeviceType": "string", + "DeviceId": "DEVICE-UUID-THREE", + "ApplicationVersion": "2.0.0", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": false, + "SupportsRemoteControl": false, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "SERVER-UUID", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + { + "PlayState": { + "PositionTicks": 220246970, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + "VolumeLevel": 100, + "MediaSourceId": "a744119f757f88858f95aab1628708c4", + "PlayMethod": "DirectPlay", + "RepeatMode": "RepeatNone" + }, + "AdditionalUsers": [], + "Capabilities": { + "PlayableMediaTypes": ["Audio", "Video"], + "SupportedCommands": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "SetShuffleQueue", + "ChannelUp", + "ChannelDown", + "PlayMediaSource", + "PlayTrailers" + ], + "SupportsMediaControl": true, + "SupportsContentUploading": false, + "SupportsPersistentIdentifier": false, + "SupportsSync": false + }, + "RemoteEndPoint": "192.168.1.254", + "PlayableMediaTypes": ["Audio", "Video"], + "Id": "SESSION-UUID-FOUR", + "UserId": "USER-UUID-TWO", + "UserName": "USER", + "Client": "Jellyfin Android", + "LastActivityDate": "2022-10-19T03:20:20.1214274Z", + "LastPlaybackCheckIn": "2022-10-19T03:20:18.0973168Z", + "DeviceName": "JELLYFIN DEVICE FOUR", + "NowPlayingItem": { + "Name": "MUSIC FILE", + "ServerId": "SERVER-UUID", + "Id": "MUSIC-UUID", + "DateCreated": "2022-10-19T03:09:11.392057Z", + "ExternalUrls": [], + "Path": "string", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 736391552, + "IndexNumber": 1, + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "4c0343ed1bbcda094178076230051b7e", + "Type": "Audio", + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "Artists": ["Contributing Artist"], + "ArtistItems": [ + { + "Name": "Contributing Artist", + "Id": "1d864900526d9a9513b489f1cc28f8ca" + } + ], + "Album": "ALBUM", + "AlbumId": "ALBUM-UUID", + "AlbumArtist": "Album Artist", + "AlbumArtists": [ + { "Name": "Album Artist", "Id": "9a65b2c222ddb34e51f5cae360fad3a1" } + ], + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Audio" + }, + "FullNowPlayingItem": { + "Size": 2356453, + "IsHD": false, + "IsShortcut": false, + "Width": 0, + "Height": 0, + "ExtraIds": [], + "DateLastSaved": "2022-10-19T03:10:11.9765475Z", + "RemoteTrailers": [], + "SupportsExternalTransfer": false + }, + "DeviceId": "DEVICE-UUID-FOUR", + "ApplicationVersion": "2.4.4", + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "a744119f757f88858f95aab1628708c4", + "PlaylistItemId": "playlistItem2" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "ServerId": "e1012aa74e1b40c8ac50f3af79e9e83f", + "Id": "a744119f757f88858f95aab1628708c4", + "Etag": "64ed7b4ce1127c5d41e685de30090383", + "DateCreated": "2022-10-19T03:09:11.392057Z", + "CanDelete": true, + "CanDownload": true, + "SortName": "string", + "ExternalUrls": [], + "MediaSources": [ + { + "Protocol": "File", + "Id": "a744119f757f88858f95aab1628708c4", + "Path": "string", + "Type": "Default", + "Container": "mp3", + "Size": 2356453, + "Name": "string", + "IsRemote": false, + "ETag": "83b0e0ece75386b479a2c3a09f71d695", + "RunTimeTicks": 736391552, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 256000, + "RequiredHttpHeaders": {} + } + ], + "Path": "string", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 736391552, + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "4c0343ed1bbcda094178076230051b7e", + "Type": "Audio", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "61bba315f137702baa296a1c417faada", + "Tags": [], + "Artists": [], + "ArtistItems": [], + "AlbumArtists": [], + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Audio", + "LockedFields": [], + "LockData": false + } + ], + "HasCustomDeviceName": false, + "PlaylistItemId": "playlistItem2", + "ServerId": "SERVER-UUID", + "SupportedCommands": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "SetShuffleQueue", + "ChannelUp", + "ChannelDown", + "PlayMediaSource", + "PlayTrailers" + ] + } +] diff --git a/tests/components/jellyfin/fixtures/user-items-parent-id.json b/tests/components/jellyfin/fixtures/user-items-parent-id.json new file mode 100644 index 0000000000000000000000000000000000000000..2e06c30894c483839e10a216351563d842590b66 --- /dev/null +++ b/tests/components/jellyfin/fixtures/user-items-parent-id.json @@ -0,0 +1,510 @@ +{ + "Items": [ + { + "Name": "EPISODE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "FOLDER-UUID", + "Type": "Episode", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "TotalRecordCount": 0, + "StartIndex": 0 +} diff --git a/tests/components/jellyfin/fixtures/user-items.json b/tests/components/jellyfin/fixtures/user-items.json new file mode 100644 index 0000000000000000000000000000000000000000..7461626de1895399f8f8bf51e5a8ea1d89ea6a07 --- /dev/null +++ b/tests/components/jellyfin/fixtures/user-items.json @@ -0,0 +1,510 @@ +{ + "Items": [ + { + "Name": "FOLDER", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "FOLDER-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "TotalRecordCount": 0, + "StartIndex": 0 +} diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index e898f8ac5cedd25a2323764ee8df3ede5a4287c5..9dc0fc86b5e2a7af19037adc2faa7e5791482c0b 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -1,21 +1,13 @@ """Test the jellyfin config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock from homeassistant import config_entries, data_entry_flow -from homeassistant.components.jellyfin.const import DOMAIN +from homeassistant.components.jellyfin.const import CONF_CLIENT_DEVICE_ID, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import ( - MOCK_SUCCESFUL_CONNECTION_STATE, - MOCK_SUCCESFUL_LOGIN_RESPONSE, - MOCK_UNSUCCESFUL_CONNECTION_STATE, - MOCK_UNSUCCESFUL_LOGIN_RESPONSE, - MOCK_USER_SETTINGS, - TEST_PASSWORD, - TEST_URL, - TEST_USERNAME, -) +from . import async_load_json_fixture +from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME from tests.common import MockConfigEntry @@ -31,52 +23,52 @@ async def test_abort_if_existing_entry(hass: HomeAssistant): assert result["reason"] == "single_instance_allowed" -async def test_form(hass: HomeAssistant): +async def test_form( + hass: HomeAssistant, + mock_jellyfin: MagicMock, + mock_client: MagicMock, + mock_client_device_id: MagicMock, + mock_setup_entry: MagicMock, +): """Test the complete configuration 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.jellyfin.client_wrapper.ConnectionManager.connect_to_address", - return_value=MOCK_SUCCESFUL_CONNECTION_STATE, - ) as mock_connect, patch( - "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.login", - return_value=MOCK_SUCCESFUL_LOGIN_RESPONSE, - ) as mock_login, patch( - "homeassistant.components.jellyfin.async_setup_entry", - return_value=True, - ) as mock_setup_entry, patch( - "homeassistant.components.jellyfin.client_wrapper.API.get_user_settings", - return_value=MOCK_USER_SETTINGS, - ) as mock_set_id: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == TEST_URL + assert result2["title"] == "JELLYFIN-SERVER" assert result2["data"] == { + CONF_CLIENT_DEVICE_ID: "TEST-UUID", CONF_URL: TEST_URL, CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, } - assert len(mock_connect.mock_calls) == 1 - assert len(mock_login.mock_calls) == 1 + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + assert len(mock_client.auth.login.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_set_id.mock_calls) == 1 + assert len(mock_client.jellyfin.get_user_settings.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant): +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_jellyfin: MagicMock, + mock_client: MagicMock, + mock_client_device_id: MagicMock, +): """Test we handle an unreachable server.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -84,27 +76,32 @@ async def test_form_cannot_connect(hass: HomeAssistant): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address", - return_value=MOCK_UNSUCCESFUL_CONNECTION_STATE, - ) as mock_connect: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, "auth-connect-address-failure.json" + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) await hass.async_block_till_done() assert result2["type"] == "form" assert result2["errors"] == {"base": "cannot_connect"} - assert len(mock_connect.mock_calls) == 1 + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant): +async def test_form_invalid_auth( + hass: HomeAssistant, + mock_jellyfin: MagicMock, + mock_client: MagicMock, + mock_client_device_id: MagicMock, +): """Test that we can handle invalid credentials.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -112,31 +109,30 @@ async def test_form_invalid_auth(hass: HomeAssistant): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address", - return_value=MOCK_SUCCESFUL_CONNECTION_STATE, - ) as mock_connect, patch( - "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.login", - return_value=MOCK_UNSUCCESFUL_LOGIN_RESPONSE, - ) as mock_login: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, "auth-login-failure.json" + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() assert result2["type"] == "form" assert result2["errors"] == {"base": "invalid_auth"} - assert len(mock_connect.mock_calls) == 1 - assert len(mock_login.mock_calls) == 1 + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + assert len(mock_client.auth.login.mock_calls) == 1 -async def test_form_exception(hass: HomeAssistant): +async def test_form_exception( + hass: HomeAssistant, mock_jellyfin: MagicMock, mock_client: MagicMock +): """Test we handle an unexpected exception during server setup.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -144,21 +140,75 @@ async def test_form_exception(hass: HomeAssistant): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address", - side_effect=Exception("UnknownException"), - ) as mock_connect: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() + mock_client.auth.connect_to_address.side_effect = Exception("UnknownException") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() assert result2["type"] == "form" assert result2["errors"] == {"base": "unknown"} - assert len(mock_connect.mock_calls) == 1 + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + + +async def test_form_persists_device_id_on_error( + hass: HomeAssistant, + mock_jellyfin: MagicMock, + mock_client: MagicMock, + mock_client_device_id: MagicMock, +): + """Test that we can handle invalid credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + mock_client_device_id.return_value = "TEST-UUID-1" + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, "auth-login-failure.json" + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + mock_client_device_id.return_value = "TEST-UUID-2" + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, "auth-login.json" + ) + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result3 + assert result3["type"] == "create_entry" + assert result3["data"] == { + CONF_CLIENT_DEVICE_ID: "TEST-UUID-1", + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + } diff --git a/tests/components/jellyfin/test_diagnostics.py b/tests/components/jellyfin/test_diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..b4c386be956dbc94f45144d6c2e7953284b7a424 --- /dev/null +++ b/tests/components/jellyfin/test_diagnostics.py @@ -0,0 +1,611 @@ +"""Test Jellyfin diagnostics.""" +from aiohttp import ClientSession + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + init_integration: MockConfigEntry, + hass_client: ClientSession, +): + """Test generating diagnostics for a config entry.""" + entry = init_integration + + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) + assert diag + assert diag["entry"] == { + "title": "Jellyfin", + "data": { + "url": "https://example.com", + "username": "test-username", + "password": "**REDACTED**", + "client_device_id": entry.entry_id, + }, + } + assert diag["server"] == { + "id": "SERVER-UUID", + "name": "JELLYFIN-SERVER", + "version": None, + } + assert diag["sessions"] + assert len(diag["sessions"]) == 4 + assert diag["sessions"][0] == { + "id": "SESSION-UUID", + "user_id": "08ba1929-681e-4b24-929b-9245852f65c0", + "device_id": "DEVICE-UUID", + "device_name": "JELLYFIN-DEVICE", + "client_name": "Jellyfin for Developers", + "client_version": "1.0.0", + "capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["VolumeSet", "Mute"], + "SupportsMediaControl": True, + "SupportsContentUploading": True, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": True, + "SupportsSync": True, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + {"Name": "string", "Value": "string", "Match": "Equals"} + ], + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": False, + "EnableSingleAlbumArtLimit": False, + "EnableSingleSubtitleLimit": False, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": False, + "RequiresPlainFolders": False, + "EnableMSMediaReceiverRegistrar": False, + "IgnoreTranscodeByteRangeRequests": False, + "XmlRootAttributes": [{"Name": "string", "Value": "string"}], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": False, + "EnableMpegtsM2TsMode": False, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": False, + "Context": "Streaming", + "EnableSubtitlesInManifest": False, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": False, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": True, + } + ], + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": True, + } + ], + "Container": "string", + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": True, + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": True, + } + ], + "Codec": "string", + "Container": "string", + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": True, + } + ], + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string", + } + ], + }, + "AppStoreUrl": "string", + "IconUrl": "string", + }, + "now_playing": { + "Name": "EPISODE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": True, + "CanDownload": True, + "HasSubtitles": True, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": True, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [{"Name": "string", "Url": "string"}], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": True, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": True, + "IgnoreDts": True, + "IgnoreIndex": True, + "GenPtsInput": True, + "SupportsTranscoding": True, + "SupportsDirectStream": True, + "SupportsDirectPlay": True, + "IsInfiniteStream": True, + "RequiresOpening": True, + "OpenToken": "string", + "RequiresClosing": True, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": True, + "SupportsProbing": True, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": True, + "IsAVC": True, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": True, + "IsForced": True, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": True, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": True, + "IsTextSubtitleStream": True, + "SupportsExternalStream": True, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": True, + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string", + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string", + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0, + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": True, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 600000000, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": True, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 3, + "IndexNumberEnd": 0, + "ParentIndexNumber": 1, + "RemoteTrailers": [{"Url": "string", "Name": "string"}], + "ProviderIds": {"property1": "string", "property2": "string"}, + "IsHD": True, + "IsFolder": False, + "ParentId": "PARENT-UUID", + "Type": "Episode", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": {"property1": "string", "property2": "string"}, + "Art": {"property1": "string", "property2": "string"}, + "Backdrop": {"property1": "string", "property2": "string"}, + "Banner": {"property1": "string", "property2": "string"}, + "Logo": {"property1": "string", "property2": "string"}, + "Thumb": {"property1": "string", "property2": "string"}, + "Disc": {"property1": "string", "property2": "string"}, + "Box": {"property1": "string", "property2": "string"}, + "Screenshot": {"property1": "string", "property2": "string"}, + "Menu": {"property1": "string", "property2": "string"}, + "Chapter": {"property1": "string", "property2": "string"}, + "BoxRear": {"property1": "string", "property2": "string"}, + "Profile": {"property1": "string", "property2": "string"}, + }, + } + ], + "Studios": [ + {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} + ], + "GenreItems": [ + {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": True, + "Likes": True, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": True, + "Key": "string", + "ItemId": "string", + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "SERIES", + "SeriesId": "SERIES-UUID", + "SeasonId": "SEASON-UUID", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} + ], + "SeasonName": "SEASON", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": True, + "IsAVC": True, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": True, + "IsForced": True, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": True, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": True, + "IsTextSubtitleStream": True, + "SupportsExternalStream": True, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": True, + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": {"property1": "string", "property2": "string"}, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": {"property1": "string", "property2": "string"}, + "Art": {"property1": "string", "property2": "string"}, + "Backdrop": {"property1": "string", "property2": "string"}, + "Banner": {"property1": "string", "property2": "string"}, + "Logo": {"property1": "string", "property2": "string"}, + "Thumb": {"property1": "string", "property2": "string"}, + "Disc": {"property1": "string", "property2": "string"}, + "Box": {"property1": "string", "property2": "string"}, + "Screenshot": {"property1": "string", "property2": "string"}, + "Menu": {"property1": "string", "property2": "string"}, + "Chapter": {"property1": "string", "property2": "string"}, + "BoxRear": {"property1": "string", "property2": "string"}, + "Profile": {"property1": "string", "property2": "string"}, + }, + "SeriesStudio": "HASS", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string", + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": True, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": True, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": True, + "IsSports": True, + "IsSeries": True, + "IsLive": True, + "IsNews": True, + "IsKids": True, + "IsPremiere": True, + "TimerId": "string", + "CurrentProgram": {}, + }, + "play_state": { + "PositionTicks": 100000000, + "CanSeek": True, + "IsPaused": True, + "IsMuted": True, + "VolumeLevel": 0, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string", + }, + } diff --git a/tests/components/jellyfin/test_init.py b/tests/components/jellyfin/test_init.py new file mode 100644 index 0000000000000000000000000000000000000000..542be0736c7881c6f90dca4e97f896a2899017ba --- /dev/null +++ b/tests/components/jellyfin/test_init.py @@ -0,0 +1,48 @@ +"""Tests for the Jellyfin integration.""" +from unittest.mock import MagicMock + +from homeassistant.components.jellyfin.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import async_load_json_fixture + +from tests.common import MockConfigEntry + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test the Jellyfin configuration entry not ready.""" + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, +) -> None: + """Test the Jellyfin configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.entry_id in hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.entry_id not in hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py new file mode 100644 index 0000000000000000000000000000000000000000..40ddfefce391aa5c0fdcb4a1dca8a43c02b54783 --- /dev/null +++ b/tests/components/jellyfin/test_media_player.py @@ -0,0 +1,356 @@ +"""Tests for the Jellyfin media_player platform.""" +from datetime import timedelta +from unittest.mock import MagicMock + +from aiohttp import ClientSession + +from homeassistant.components.jellyfin.const import DOMAIN +from homeassistant.components.media_player import ( + ATTR_MEDIA_ALBUM_ARTIST, + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_EPISODE, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_SEASON, + ATTR_MEDIA_SERIES_TITLE, + ATTR_MEDIA_TRACK, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MP_DOMAIN, + MediaClass, + MediaPlayerState, + MediaType, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.dt import utcnow + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_media_player( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test the Jellyfin media player.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("media_player.jellyfin_device") + + assert state + assert state.state == MediaPlayerState.PAUSED + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN-DEVICE" + assert state.attributes.get(ATTR_ICON) is None + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0.0 + assert state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True + assert state.attributes.get(ATTR_MEDIA_DURATION) == 60 + assert state.attributes.get(ATTR_MEDIA_POSITION) == 10 + assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) + assert state.attributes.get(ATTR_MEDIA_CONTENT_ID) == "EPISODE-UUID" + assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MediaType.TVSHOW + assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) == "SERIES" + assert state.attributes.get(ATTR_MEDIA_SEASON) == 1 + assert state.attributes.get(ATTR_MEDIA_EPISODE) == 3 + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is None + assert entry.unique_id == "SERVER-UUID-SESSION-UUID" + + assert len(mock_api.sessions.mock_calls) == 1 + async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert len(mock_api.sessions.mock_calls) == 2 + + mock_api.sessions.return_value = [] + async_fire_time_changed(hass, utcnow() + timedelta(seconds=20)) + await hass.async_block_till_done() + assert len(mock_api.sessions.mock_calls) == 3 + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == set() + assert device.entry_type is None + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "DEVICE-UUID")} + assert device.manufacturer == "Jellyfin" + assert device.name == "JELLYFIN-DEVICE" + assert device.sw_version == "1.0.0" + + +async def test_media_player_music( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test the Jellyfin media player.""" + entity_registry = er.async_get(hass) + + state = hass.states.get("media_player.jellyfin_device_four") + + assert state + assert state.state == MediaPlayerState.PLAYING + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN DEVICE FOUR" + assert state.attributes.get(ATTR_ICON) is None + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 1.0 + assert state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is False + assert state.attributes.get(ATTR_MEDIA_DURATION) == 73 + assert state.attributes.get(ATTR_MEDIA_POSITION) == 22 + assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) + assert state.attributes.get(ATTR_MEDIA_CONTENT_ID) == "MUSIC-UUID" + assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MediaType.MUSIC + assert state.attributes.get(ATTR_MEDIA_ALBUM_NAME) == "ALBUM" + assert state.attributes.get(ATTR_MEDIA_ALBUM_ARTIST) == "Album Artist" + assert state.attributes.get(ATTR_MEDIA_ARTIST) == "Contributing Artist" + assert state.attributes.get(ATTR_MEDIA_TRACK) == 1 + assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) is None + assert state.attributes.get(ATTR_MEDIA_SEASON) is None + assert state.attributes.get(ATTR_MEDIA_EPISODE) is None + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id is None + assert entry.entity_category is None + assert entry.unique_id == "SERVER-UUID-SESSION-UUID-FOUR" + + +async def test_services( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test Jellyfin media player services.""" + state = hass.states.get("media_player.jellyfin_device") + assert state + + await hass.services.async_call( + MP_DOMAIN, + "play_media", + { + ATTR_ENTITY_ID: state.entity_id, + "media_content_type": "", + "media_content_id": "ITEM-UUID", + }, + blocking=True, + ) + assert len(mock_api.remote_play_media.mock_calls) == 1 + assert mock_api.remote_play_media.mock_calls[0].args == ( + "SESSION-UUID", + ["ITEM-UUID"], + ) + + await hass.services.async_call( + MP_DOMAIN, + "media_pause", + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) + assert len(mock_api.remote_pause.mock_calls) == 1 + + await hass.services.async_call( + MP_DOMAIN, + "media_play", + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) + assert len(mock_api.remote_unpause.mock_calls) == 1 + + await hass.services.async_call( + MP_DOMAIN, + "media_play_pause", + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) + assert len(mock_api.remote_playpause.mock_calls) == 1 + + await hass.services.async_call( + MP_DOMAIN, + "media_seek", + { + ATTR_ENTITY_ID: state.entity_id, + "seek_position": 10, + }, + blocking=True, + ) + assert len(mock_api.remote_seek.mock_calls) == 1 + assert mock_api.remote_seek.mock_calls[0].args == ( + "SESSION-UUID", + 100000000, + ) + + await hass.services.async_call( + MP_DOMAIN, + "media_stop", + { + ATTR_ENTITY_ID: state.entity_id, + }, + blocking=True, + ) + assert len(mock_api.remote_stop.mock_calls) == 1 + + await hass.services.async_call( + MP_DOMAIN, + "volume_set", + { + ATTR_ENTITY_ID: state.entity_id, + "volume_level": 0.5, + }, + blocking=True, + ) + assert len(mock_api.remote_set_volume.mock_calls) == 1 + + await hass.services.async_call( + MP_DOMAIN, + "volume_mute", + { + ATTR_ENTITY_ID: state.entity_id, + "is_volume_muted": True, + }, + blocking=True, + ) + assert len(mock_api.remote_mute.mock_calls) == 1 + + await hass.services.async_call( + MP_DOMAIN, + "volume_mute", + { + ATTR_ENTITY_ID: state.entity_id, + "is_volume_muted": False, + }, + blocking=True, + ) + assert len(mock_api.remote_unmute.mock_calls) == 1 + + +async def test_browse_media( + hass: HomeAssistant, + hass_ws_client: ClientSession, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test Jellyfin browse media.""" + client = await hass_ws_client() + + # browse root folder + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.jellyfin_device", + } + ) + response = await client.receive_json() + assert response["success"] + expected_child_item = { + "title": "COLLECTION FOLDER", + "media_class": MediaClass.DIRECTORY.value, + "media_content_type": "collection", + "media_content_id": "COLLECTION-FOLDER-UUID", + "can_play": False, + "can_expand": True, + "thumbnail": "http://localhost/Items/c22fd826-17fc-44f4-9b04-1eb3e8fb9173/Images/Backdrop.jpg", + "children_media_class": None, + } + + assert response["result"]["media_content_id"] == "" + assert response["result"]["media_content_type"] == "root" + assert response["result"]["title"] == "Jellyfin" + assert response["result"]["children"][0] == expected_child_item + + # browse collection folder + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": "media_player.jellyfin_device", + "media_content_type": "collection", + "media_content_id": "COLLECTION-FOLDER-UUID", + } + ) + + response = await client.receive_json() + expected_child_item = { + "title": "EPISODE", + "media_class": MediaClass.EPISODE.value, + "media_content_type": MediaType.EPISODE.value, + "media_content_id": "EPISODE-UUID", + "can_play": True, + "can_expand": False, + "thumbnail": "http://localhost/Items/c22fd826-17fc-44f4-9b04-1eb3e8fb9173/Images/Backdrop.jpg", + "children_media_class": None, + } + + assert response["success"] + assert response["result"]["media_content_id"] == "COLLECTION-FOLDER-UUID" + assert response["result"]["title"] == "FOLDER" + assert response["result"]["children"][0] == expected_child_item + + # browse for collection without children + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = {} + + await client.send_json( + { + "id": 3, + "type": "media_player/browse_media", + "entity_id": "media_player.jellyfin_device", + "media_content_type": "collection", + "media_content_id": "COLLECTION-FOLDER-UUID", + } + ) + + response = await client.receive_json() + assert response["success"] is False + assert response["error"] + assert ( + response["error"]["message"] + == "Media not found: collection / COLLECTION-FOLDER-UUID" + ) + + # browse for non-existent item + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = {} + + await client.send_json( + { + "id": 4, + "type": "media_player/browse_media", + "entity_id": "media_player.jellyfin_device", + "media_content_type": "collection", + "media_content_id": "COLLECTION-UUID-404", + } + ) + + response = await client.receive_json() + assert response["success"] is False + assert response["error"] + assert ( + response["error"]["message"] + == "Media not found: collection / COLLECTION-UUID-404" + ) diff --git a/tests/components/jellyfin/test_sensor.py b/tests/components/jellyfin/test_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..087be30b70c94238ab4c2cad442af432c0918cc8 --- /dev/null +++ b/tests/components/jellyfin/test_sensor.py @@ -0,0 +1,51 @@ +"""Tests for the Jellyfin sensor platform.""" +from unittest.mock import MagicMock + +from homeassistant.components.jellyfin.const import DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_watching( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, +) -> None: + """Test the Jellyfin watching sensor.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.jellyfin_server") + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN-SERVER" + assert state.attributes.get(ATTR_ICON) == "mdi:television-play" + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Watching" + assert state.state == "3" + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is None + assert entry.unique_id == "SERVER-UUID-watching" + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == set() + assert device.entry_type is dr.DeviceEntryType.SERVICE + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "SERVER-UUID")} + assert device.manufacturer == "Jellyfin" + assert device.name == "JELLYFIN-SERVER" + assert device.sw_version is None diff --git a/tests/components/keymitt_ble/__init__.py b/tests/components/keymitt_ble/__init__.py index 7ae4c20c40652525a8784f125030d930774e4b00..136ca99c56dc02c3c551fd62f809215745a25565 100644 --- a/tests/components/keymitt_ble/__init__.py +++ b/tests/components/keymitt_ble/__init__.py @@ -2,11 +2,12 @@ from unittest.mock import patch from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.const import CONF_ADDRESS +from tests.components.bluetooth import generate_advertisement_data + DOMAIN = "keymitt_ble" ENTRY_CONFIG = { @@ -38,7 +39,7 @@ SERVICE_INFO = BluetoothServiceInfoBleak( service_data={}, rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( local_name="mibp", manufacturer_data={}, service_uuids=["0000abcd-0000-1000-8000-00805f9b34fb"], diff --git a/tests/components/kira/test_remote.py b/tests/components/kira/test_remote.py index e91cbaca8916a84ef97aa16a38407abd0527021a..03268200077ea017536ed83e4012930dcd81dd02 100644 --- a/tests/components/kira/test_remote.py +++ b/tests/components/kira/test_remote.py @@ -1,48 +1,38 @@ """The tests for Kira sensor platform.""" -import unittest from unittest.mock import MagicMock from homeassistant.components.kira import remote as kira -from tests.common import get_test_home_assistant - SERVICE_SEND_COMMAND = "send_command" TEST_CONFIG = {kira.DOMAIN: {"devices": [{"host": "127.0.0.1", "port": 17324}]}} DISCOVERY_INFO = {"name": "kira", "device": "kira"} +DEVICES = [] -class TestKiraSensor(unittest.TestCase): - """Tests the Kira Sensor platform.""" - # pylint: disable=invalid-name - DEVICES = [] +def add_entities(devices): + """Mock add devices.""" + for device in devices: + DEVICES.append(device) - def add_entities(self, devices): - """Mock add devices.""" - for device in devices: - self.DEVICES.append(device) - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() - self.mock_kira = MagicMock() - self.hass.data[kira.DOMAIN] = {kira.CONF_REMOTE: {}} - self.hass.data[kira.DOMAIN][kira.CONF_REMOTE]["kira"] = self.mock_kira - self.addCleanup(self.hass.stop) +def test_service_call(hass): + """Test Kira's ability to send commands.""" + mock_kira = MagicMock() + hass.data[kira.DOMAIN] = {kira.CONF_REMOTE: {}} + hass.data[kira.DOMAIN][kira.CONF_REMOTE]["kira"] = mock_kira - def test_service_call(self): - """Test Kira's ability to send commands.""" - kira.setup_platform(self.hass, TEST_CONFIG, self.add_entities, DISCOVERY_INFO) - assert len(self.DEVICES) == 1 - remote = self.DEVICES[0] + kira.setup_platform(hass, TEST_CONFIG, add_entities, DISCOVERY_INFO) + assert len(DEVICES) == 1 + remote = DEVICES[0] - assert remote.name == "kira" + assert remote.name == "kira" - command = ["FAKE_COMMAND"] - device = "FAKE_DEVICE" - commandTuple = (command[0], device) - remote.send_command(device=device, command=command) + command = ["FAKE_COMMAND"] + device = "FAKE_DEVICE" + commandTuple = (command[0], device) + remote.send_command(device=device, command=command) - self.mock_kira.sendCode.assert_called_with(commandTuple) + mock_kira.sendCode.assert_called_with(commandTuple) diff --git a/tests/components/kira/test_sensor.py b/tests/components/kira/test_sensor.py index b835a25ae9095b3f57e3b87a35371f62107cfdf1..f0c771fbda0540b069707f253623d374f7a13db8 100644 --- a/tests/components/kira/test_sensor.py +++ b/tests/components/kira/test_sensor.py @@ -1,50 +1,42 @@ """The tests for Kira sensor platform.""" -import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from homeassistant.components.kira import sensor as kira -from tests.common import get_test_home_assistant - TEST_CONFIG = {kira.DOMAIN: {"sensors": [{"host": "127.0.0.1", "port": 17324}]}} DISCOVERY_INFO = {"name": "kira", "device": "kira"} +DEVICES = [] + -class TestKiraSensor(unittest.TestCase): - """Tests the Kira Sensor platform.""" +def add_entities(devices): + """Mock add devices.""" + for device in devices: + DEVICES.append(device) - # pylint: disable=invalid-name - DEVICES = [] - def add_entities(self, devices): - """Mock add devices.""" - for device in devices: - self.DEVICES.append(device) +@patch("homeassistant.components.kira.sensor.KiraReceiver.schedule_update_ha_state") +def test_kira_sensor_callback(mock_schedule_update_ha_state, hass): + """Ensure Kira sensor properly updates its attributes from callback.""" + mock_kira = MagicMock() + hass.data[kira.DOMAIN] = {kira.CONF_SENSOR: {}} + hass.data[kira.DOMAIN][kira.CONF_SENSOR]["kira"] = mock_kira - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() - mock_kira = MagicMock() - self.hass.data[kira.DOMAIN] = {kira.CONF_SENSOR: {}} - self.hass.data[kira.DOMAIN][kira.CONF_SENSOR]["kira"] = mock_kira - self.addCleanup(self.hass.stop) + kira.setup_platform(hass, TEST_CONFIG, add_entities, DISCOVERY_INFO) + assert len(DEVICES) == 1 + sensor = DEVICES[0] - # pylint: disable=protected-access - def test_kira_sensor_callback(self): - """Ensure Kira sensor properly updates its attributes from callback.""" - kira.setup_platform(self.hass, TEST_CONFIG, self.add_entities, DISCOVERY_INFO) - assert len(self.DEVICES) == 1 - sensor = self.DEVICES[0] + assert sensor.name == "kira" - assert sensor.name == "kira" + sensor.hass = hass - sensor.hass = self.hass + codeName = "FAKE_CODE" + deviceName = "FAKE_DEVICE" + codeTuple = (codeName, deviceName) + sensor._update_callback(codeTuple) - codeName = "FAKE_CODE" - deviceName = "FAKE_DEVICE" - codeTuple = (codeName, deviceName) - sensor._update_callback(codeTuple) + mock_schedule_update_ha_state.assert_called - assert sensor.state == codeName - assert sensor.extra_state_attributes == {kira.CONF_DEVICE: deviceName} + assert sensor.state == codeName + assert sensor.extra_state_attributes == {kira.CONF_DEVICE: deviceName} diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index 56cf5b2c00a6c57fa0ac2d0c14beb06b248bc6e0..2f7484fad8b117eb1e8cd2e729187c0d6a8e8cfb 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -11,7 +11,7 @@ from homeassistant.components.knx.schema import LightSchema from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_RGBW_COLOR, ColorMode, @@ -166,19 +166,25 @@ async def test_light_color_temp_absolute(hass: HomeAssistant, knx: KNXTestKit): brightness=255, color_mode=ColorMode.COLOR_TEMP, color_temp=370, + color_temp_kelvin=2700, ) # change color temperature from HA await hass.services.async_call( "light", "turn_on", - {"entity_id": "light.test", ATTR_COLOR_TEMP: 250}, # 4000 Kelvin - 0x0FA0 + {"entity_id": "light.test", ATTR_COLOR_TEMP_KELVIN: 4000}, # 4000 - 0x0FA0 blocking=True, ) await knx.assert_write(test_ct, (0x0F, 0xA0)) knx.assert_state("light.test", STATE_ON, color_temp=250) # change color temperature from KNX await knx.receive_write(test_ct_state, (0x17, 0x70)) # 6000 Kelvin - 166 Mired - knx.assert_state("light.test", STATE_ON, color_temp=166) + knx.assert_state( + "light.test", + STATE_ON, + color_temp=166, + color_temp_kelvin=6000, + ) async def test_light_color_temp_relative(hass: HomeAssistant, knx: KNXTestKit): @@ -222,19 +228,33 @@ async def test_light_color_temp_relative(hass: HomeAssistant, knx: KNXTestKit): brightness=255, color_mode=ColorMode.COLOR_TEMP, color_temp=250, + color_temp_kelvin=4000, ) # change color temperature from HA await hass.services.async_call( "light", "turn_on", - {"entity_id": "light.test", ATTR_COLOR_TEMP: 300}, # 3333 Kelvin - 33 % - 0x54 + { + "entity_id": "light.test", + ATTR_COLOR_TEMP_KELVIN: 3333, # 3333 Kelvin - 33.3 % - 0x55 + }, blocking=True, ) - await knx.assert_write(test_ct, (0x54,)) - knx.assert_state("light.test", STATE_ON, color_temp=300) + await knx.assert_write(test_ct, (0x55,)) + knx.assert_state( + "light.test", + STATE_ON, + color_temp=300, + color_temp_kelvin=3333, + ) # change color temperature from KNX - await knx.receive_write(test_ct_state, (0xE6,)) # 3900 Kelvin - 90 % - 256 Mired - knx.assert_state("light.test", STATE_ON, color_temp=256) + await knx.receive_write(test_ct_state, (0xE6,)) # 3901 Kelvin - 90.1 % - 256 Mired + knx.assert_state( + "light.test", + STATE_ON, + color_temp=256, + color_temp_kelvin=3901, + ) async def test_light_hs_color(hass: HomeAssistant, knx: KNXTestKit): diff --git a/tests/components/lametric/test_button.py b/tests/components/lametric/test_button.py index cd55c9914f5ca831bb5483d96e9616795b370209..db1f0c57929d75e8a66b1092c852701eb06eee8f 100644 --- a/tests/components/lametric/test_button.py +++ b/tests/components/lametric/test_button.py @@ -1,12 +1,19 @@ """Tests for the LaMetric button platform.""" from unittest.mock import MagicMock +from demetriek import LaMetricConnectionError, LaMetricError import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.lametric.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ICON, + STATE_UNAVAILABLE, + 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 @@ -111,3 +118,156 @@ async def test_button_app_previous( state = hass.states.get("button.frenck_s_lametric_previous_app") assert state assert state.state == "2022-09-19T12:07:30+00:00" + + +@pytest.mark.freeze_time("2022-10-19 12:44:00") +async def test_button_dismiss_current_notification( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric dismiss current notification button.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("button.frenck_s_lametric_dismiss_current_notification") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:bell-cancel" + assert state.state == STATE_UNKNOWN + + entry = entity_registry.async_get( + "button.frenck_s_lametric_dismiss_current_notification" + ) + assert entry + assert entry.unique_id == "SA110405124500W00BS9-dismiss_current" + assert entry.entity_category == EntityCategory.CONFIG + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.configuration_url is None + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + } + assert device_entry.entry_type is None + assert device_entry.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device_entry.manufacturer == "LaMetric Inc." + assert device_entry.model == "LM 37X8" + assert device_entry.name == "Frenck's LaMetric" + assert device_entry.sw_version == "2.2.2" + assert device_entry.hw_version is None + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.frenck_s_lametric_dismiss_current_notification"}, + blocking=True, + ) + + assert len(mock_lametric.dismiss_current_notification.mock_calls) == 1 + mock_lametric.dismiss_current_notification.assert_called_with() + + state = hass.states.get("button.frenck_s_lametric_dismiss_current_notification") + assert state + assert state.state == "2022-10-19T12:44:00+00:00" + + +@pytest.mark.freeze_time("2022-10-19 12:44:00") +async def test_button_dismiss_all_notifications( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric dismiss all notifications button.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("button.frenck_s_lametric_dismiss_all_notifications") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:bell-cancel" + assert state.state == STATE_UNKNOWN + + entry = entity_registry.async_get( + "button.frenck_s_lametric_dismiss_all_notifications" + ) + assert entry + assert entry.unique_id == "SA110405124500W00BS9-dismiss_all" + assert entry.entity_category == EntityCategory.CONFIG + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.configuration_url is None + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + } + assert device_entry.entry_type is None + assert device_entry.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device_entry.manufacturer == "LaMetric Inc." + assert device_entry.model == "LM 37X8" + assert device_entry.name == "Frenck's LaMetric" + assert device_entry.sw_version == "2.2.2" + assert device_entry.hw_version is None + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.frenck_s_lametric_dismiss_all_notifications"}, + blocking=True, + ) + + assert len(mock_lametric.dismiss_all_notifications.mock_calls) == 1 + mock_lametric.dismiss_all_notifications.assert_called_with() + + state = hass.states.get("button.frenck_s_lametric_dismiss_all_notifications") + assert state + assert state.state == "2022-10-19T12:44:00+00:00" + + +@pytest.mark.freeze_time("2022-10-11 22:00:00") +async def test_button_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test error handling of the LaMetric buttons.""" + mock_lametric.app_next.side_effect = LaMetricError + + with pytest.raises( + HomeAssistantError, match="Invalid response from the LaMetric device" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.frenck_s_lametric_next_app"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("button.frenck_s_lametric_next_app") + assert state + assert state.state == "2022-10-11T22:00:00+00:00" + + +async def test_button_connection_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test connection error handling of the LaMetric buttons.""" + mock_lametric.app_next.side_effect = LaMetricConnectionError + + with pytest.raises( + HomeAssistantError, match="Error communicating with the LaMetric device" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.frenck_s_lametric_next_app"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("button.frenck_s_lametric_next_app") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lametric/test_config_flow.py b/tests/components/lametric/test_config_flow.py index 338fe5052d1a3f405f6ce77235ba3f09cf299944..a23b50c9813788c1493d85b947adbc5ffc9471d3 100644 --- a/tests/components/lametric/test_config_flow.py +++ b/tests/components/lametric/test_config_flow.py @@ -18,7 +18,12 @@ from homeassistant.components.ssdp import ( ATTR_UPNP_SERIAL, SsdpServiceInfo, ) -from homeassistant.config_entries import SOURCE_DHCP, SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_DHCP, + SOURCE_REAUTH, + SOURCE_SSDP, + SOURCE_USER, +) from homeassistant.const import CONF_API_KEY, CONF_DEVICE, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -743,3 +748,173 @@ async def test_dhcp_unknown_device( assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "unknown" + + +async def test_reauth_cloud_import( + hass: HomeAssistant, + hass_client_no_auth: Callable[[], Awaitable[TestClient]], + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + mock_setup_entry: MagicMock, + mock_lametric_cloud_config_flow: MagicMock, + mock_lametric_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow importing api keys from the cloud.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert "flow_id" in result + flow_id = result["flow_id"] + + await hass.config_entries.flow.async_configure( + flow_id, user_input={"next_step_id": "pick_implementation"} + ) + + # pylint: disable=protected-access + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": flow_id, + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + aioclient_mock.post( + "https://developer.lametric.com/api/v2/oauth2/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result2 = await hass.config_entries.flow.async_configure(flow_id) + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_API_KEY: "mock-api-key", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + + assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 + assert len(mock_lametric_config_flow.device.mock_calls) == 1 + assert len(mock_lametric_config_flow.notify.mock_calls) == 1 + + +async def test_reauth_cloud_abort_device_not_found( + hass: HomeAssistant, + hass_client_no_auth: Callable[[], Awaitable[TestClient]], + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + mock_setup_entry: MagicMock, + mock_lametric_cloud_config_flow: MagicMock, + mock_lametric_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow importing api keys from the cloud.""" + mock_config_entry.unique_id = "UKNOWN_DEVICE" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert "flow_id" in result + flow_id = result["flow_id"] + + await hass.config_entries.flow.async_configure( + flow_id, user_input={"next_step_id": "pick_implementation"} + ) + + # pylint: disable=protected-access + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": flow_id, + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + aioclient_mock.post( + "https://developer.lametric.com/api/v2/oauth2/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result2 = await hass.config_entries.flow.async_configure(flow_id) + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_device_not_found" + + assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 + assert len(mock_lametric_config_flow.device.mock_calls) == 0 + assert len(mock_lametric_config_flow.notify.mock_calls) == 0 + + +async def test_reauth_manual( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_lametric_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with manual entry.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert "flow_id" in result + flow_id = result["flow_id"] + + await hass.config_entries.flow.async_configure( + flow_id, user_input={"next_step_id": "manual_entry"} + ) + + result2 = await hass.config_entries.flow.async_configure( + flow_id, user_input={CONF_API_KEY: "mock-api-key"} + ) + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_API_KEY: "mock-api-key", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + + assert len(mock_lametric_config_flow.device.mock_calls) == 1 + assert len(mock_lametric_config_flow.notify.mock_calls) == 1 diff --git a/tests/components/lametric/test_diagnostics.py b/tests/components/lametric/test_diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..27c031d19e434b4ae6baf645208da726fe64f5e4 --- /dev/null +++ b/tests/components/lametric/test_diagnostics.py @@ -0,0 +1,57 @@ +"""Tests for the diagnostics data provided by the LaMetric integration.""" +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +) -> None: + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "device_id": REDACTED, + "name": REDACTED, + "serial_number": REDACTED, + "os_version": "2.2.2", + "mode": "auto", + "model": "LM 37X8", + "audio": { + "volume": 100, + "volume_range": {"range_min": 0, "range_max": 100}, + "volume_limit": {"range_min": 0, "range_max": 100}, + }, + "bluetooth": { + "available": True, + "name": REDACTED, + "active": False, + "discoverable": True, + "pairable": True, + "address": "AA:BB:CC:DD:EE:FF", + }, + "display": { + "brightness": 100, + "brightness_mode": "auto", + "width": 37, + "height": 8, + "display_type": "mixed", + }, + "wifi": { + "active": True, + "mac": "AA:BB:CC:DD:EE:FF", + "available": True, + "encryption": "WPA", + "ssid": REDACTED, + "ip": "127.0.0.1", + "mode": "dhcp", + "netmask": "255.255.255.0", + "rssi": 21, + }, + } diff --git a/tests/components/lametric/test_helpers.py b/tests/components/lametric/test_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..9a03a4d52cf3e23532277c4566dbb0ae5c9b42fd --- /dev/null +++ b/tests/components/lametric/test_helpers.py @@ -0,0 +1,38 @@ +"""Tests for the LaMetric helpers.""" +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.lametric.helpers import async_get_coordinator_by_device_id +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_get_coordinator_by_device_id( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test get LaMetric coordinator by device ID .""" + entity_registry = er.async_get(hass) + + with pytest.raises(ValueError, match="Unknown LaMetric device ID: bla"): + async_get_coordinator_by_device_id(hass, "bla") + + entry = entity_registry.async_get("button.frenck_s_lametric_next_app") + assert entry + assert entry.device_id + + coordinator = async_get_coordinator_by_device_id(hass, entry.device_id) + assert coordinator.data == mock_lametric.device.return_value + + # Unload entry + await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + + with pytest.raises( + ValueError, match=f"No coordinator for device ID: {entry.device_id}" + ): + async_get_coordinator_by_device_id(hass, entry.device_id) diff --git a/tests/components/lametric/test_init.py b/tests/components/lametric/test_init.py index 965264e891705b2748c54cc421036f70b0e82ad6..50695fc4e55baedf06c0e2970b61b7b6d4a13345 100644 --- a/tests/components/lametric/test_init.py +++ b/tests/components/lametric/test_init.py @@ -3,11 +3,15 @@ from collections.abc import Awaitable, Callable from unittest.mock import MagicMock from aiohttp import ClientWebSocketResponse -from demetriek import LaMetricConnectionError, LaMetricConnectionTimeoutError +from demetriek import ( + LaMetricAuthenticationError, + LaMetricConnectionError, + LaMetricConnectionTimeoutError, +) import pytest from homeassistant.components.lametric.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -70,3 +74,30 @@ async def test_yaml_config_raises_repairs( issues = await get_repairs(hass, hass_ws_client) assert len(issues) == 1 assert issues[0]["issue_id"] == "manual_migration" + + +async def test_config_entry_authentication_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test trigger reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + mock_lametric.device.side_effect = LaMetricAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "choice_enter_manual_or_fetch_cloud" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/lametric/test_notify.py b/tests/components/lametric/test_notify.py new file mode 100644 index 0000000000000000000000000000000000000000..3b581c81e75b79e5f80ca157f5c34dbcf0f7847e --- /dev/null +++ b/tests/components/lametric/test_notify.py @@ -0,0 +1,124 @@ +"""Tests for the LaMetric notify platform.""" +from unittest.mock import MagicMock + +from demetriek import ( + LaMetricError, + Notification, + NotificationIconType, + NotificationPriority, + NotificationSound, + NotificationSoundCategory, + Simple, +) +import pytest + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + DOMAIN as NOTIFY_DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + +NOTIFY_SERVICE = "frenck_s_lametric" + + +async def test_notification_defaults( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric notification defaults.""" + await hass.services.async_call( + NOTIFY_DOMAIN, + NOTIFY_SERVICE, + { + ATTR_MESSAGE: "Try not to become a man of success. Rather become a man of value", + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.icon_type is NotificationIconType.NONE + assert notification.life_time is None + assert notification.model.cycles == 1 + assert notification.model.sound is None + assert notification.notification_id is None + assert notification.notification_type is None + assert notification.priority is NotificationPriority.INFO + + assert len(notification.model.frames) == 1 + frame = notification.model.frames[0] + assert type(frame) is Simple + assert frame.icon == "a7956" + assert ( + frame.text == "Try not to become a man of success. Rather become a man of value" + ) + + +async def test_notification_options( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric notification options.""" + await hass.services.async_call( + NOTIFY_DOMAIN, + NOTIFY_SERVICE, + { + ATTR_MESSAGE: "The secret of getting ahead is getting started", + ATTR_DATA: { + "icon": "1234", + "sound": "positive1", + "cycles": 3, + "icon_type": "alert", + "priority": "critical", + }, + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.icon_type is NotificationIconType.ALERT + assert notification.life_time is None + assert notification.model.cycles == 3 + assert notification.model.sound is not None + assert notification.model.sound.category is NotificationSoundCategory.NOTIFICATIONS + assert notification.model.sound.sound is NotificationSound.POSITIVE1 + assert notification.model.sound.repeat == 1 + assert notification.notification_id is None + assert notification.notification_type is None + assert notification.priority is NotificationPriority.CRITICAL + + assert len(notification.model.frames) == 1 + frame = notification.model.frames[0] + assert type(frame) is Simple + assert frame.icon == 1234 + assert frame.text == "The secret of getting ahead is getting started" + + +async def test_notification_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric notification error.""" + mock_lametric.notify.side_effect = LaMetricError + + with pytest.raises( + HomeAssistantError, match="Could not send LaMetric notification" + ): + await hass.services.async_call( + NOTIFY_DOMAIN, + NOTIFY_SERVICE, + { + ATTR_MESSAGE: "It's failure that gives you the proper perspective on success", + }, + blocking=True, + ) diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py new file mode 100644 index 0000000000000000000000000000000000000000..2ecfc43246eb4c79366dc2b94b327fd79c19ff96 --- /dev/null +++ b/tests/components/lametric/test_number.py @@ -0,0 +1,195 @@ +"""Tests for the LaMetric number platform.""" +from unittest.mock import MagicMock + +from demetriek import LaMetricConnectionError, LaMetricError +import pytest + +from homeassistant.components.lametric.const import DOMAIN +from homeassistant.components.number import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + 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 homeassistant.helpers.entity import EntityCategory + +from tests.common import MockConfigEntry + + +async def test_brightness( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric display brightness controls.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("number.frenck_s_lametric_brightness") + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Brightness" + assert state.attributes.get(ATTR_ICON) == "mdi:brightness-6" + assert state.attributes.get(ATTR_MAX) == 100 + assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_STEP) == 1 + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.state == "100" + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is EntityCategory.CONFIG + assert entry.unique_id == "SA110405124500W00BS9-brightness" + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.entry_type is None + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device.manufacturer == "LaMetric Inc." + assert device.name == "Frenck's LaMetric" + assert device.sw_version == "2.2.2" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.frenck_s_lametric_brightness", + ATTR_VALUE: 21, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(mock_lametric.display.mock_calls) == 1 + mock_lametric.display.assert_called_once_with(brightness=21) + + +async def test_volume( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric volume controls.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("number.frenck_s_lametric_volume") + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Volume" + assert state.attributes.get(ATTR_ICON) == "mdi:volume-high" + assert state.attributes.get(ATTR_MAX) == 100 + assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_STEP) == 1 + assert state.state == "100" + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is EntityCategory.CONFIG + assert entry.unique_id == "SA110405124500W00BS9-volume" + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.entry_type is None + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device.manufacturer == "LaMetric Inc." + assert device.name == "Frenck's LaMetric" + assert device.sw_version == "2.2.2" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.frenck_s_lametric_volume", + ATTR_VALUE: 42, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(mock_lametric.audio.mock_calls) == 1 + mock_lametric.audio.assert_called_once_with(volume=42) + + +async def test_number_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test error handling of the LaMetric numbers.""" + mock_lametric.audio.side_effect = LaMetricError + + state = hass.states.get("number.frenck_s_lametric_volume") + assert state + assert state.state == "100" + + with pytest.raises( + HomeAssistantError, match="Invalid response from the LaMetric device" + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.frenck_s_lametric_volume", + ATTR_VALUE: 42, + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.frenck_s_lametric_volume") + assert state + assert state.state == "100" + + +async def test_number_connection_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test connection error handling of the LaMetric numbers.""" + mock_lametric.audio.side_effect = LaMetricConnectionError + + state = hass.states.get("number.frenck_s_lametric_volume") + assert state + assert state.state == "100" + + with pytest.raises( + HomeAssistantError, match="Error communicating with the LaMetric device" + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.frenck_s_lametric_volume", + ATTR_VALUE: 42, + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.frenck_s_lametric_volume") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lametric/test_select.py b/tests/components/lametric/test_select.py new file mode 100644 index 0000000000000000000000000000000000000000..65c1df2ab3dc424129d31f5fec3b65aa2c1d6afc --- /dev/null +++ b/tests/components/lametric/test_select.py @@ -0,0 +1,136 @@ +"""Tests for the LaMetric select platform.""" +from unittest.mock import MagicMock + +from demetriek import BrightnessMode, LaMetricConnectionError, LaMetricError +import pytest + +from homeassistant.components.lametric.const import DOMAIN +from homeassistant.components.select import ( + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_OPTION, + 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 homeassistant.helpers.entity import EntityCategory + +from tests.common import MockConfigEntry + + +async def test_brightness_mode( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric brightness mode controls.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("select.frenck_s_lametric_brightness_mode") + assert state + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Brightness mode" + ) + assert state.attributes.get(ATTR_ICON) == "mdi:brightness-auto" + assert state.attributes.get(ATTR_OPTIONS) == ["auto", "manual"] + assert state.state == BrightnessMode.AUTO + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is EntityCategory.CONFIG + assert entry.unique_id == "SA110405124500W00BS9-brightness_mode" + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.entry_type is None + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device.manufacturer == "LaMetric Inc." + assert device.name == "Frenck's LaMetric" + assert device.sw_version == "2.2.2" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.frenck_s_lametric_brightness_mode", + ATTR_OPTION: "manual", + }, + blocking=True, + ) + + assert len(mock_lametric.display.mock_calls) == 1 + mock_lametric.display.assert_called_once_with(brightness_mode=BrightnessMode.MANUAL) + + +async def test_select_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test error handling of the LaMetric selects.""" + mock_lametric.display.side_effect = LaMetricError + + state = hass.states.get("select.frenck_s_lametric_brightness_mode") + assert state + assert state.state == BrightnessMode.AUTO + + with pytest.raises( + HomeAssistantError, match="Invalid response from the LaMetric device" + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.frenck_s_lametric_brightness_mode", + ATTR_OPTION: "manual", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.frenck_s_lametric_brightness_mode") + assert state + assert state.state == BrightnessMode.AUTO + + +async def test_select_connection_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test connection error handling of the LaMetric selects.""" + mock_lametric.display.side_effect = LaMetricConnectionError + + state = hass.states.get("select.frenck_s_lametric_brightness_mode") + assert state + assert state.state == BrightnessMode.AUTO + + with pytest.raises( + HomeAssistantError, match="Error communicating with the LaMetric device" + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.frenck_s_lametric_brightness_mode", + ATTR_OPTION: "manual", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.frenck_s_lametric_brightness_mode") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lametric/test_sensor.py b/tests/components/lametric/test_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..76f584b1cde30a2b6f799d1db8cfdc1565593072 --- /dev/null +++ b/tests/components/lametric/test_sensor.py @@ -0,0 +1,54 @@ +"""Tests for the LaMetric sensor platform.""" +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.lametric.const import DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory + +from tests.common import MockConfigEntry + + +async def test_wifi_signal( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric Wi-Fi sensor.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.frenck_s_lametric_wi_fi_signal") + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Wi-Fi signal" + assert state.attributes.get(ATTR_ICON) == "mdi:wifi" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.state == "21" + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is EntityCategory.DIAGNOSTIC + assert entry.unique_id == "SA110405124500W00BS9-rssi" + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.entry_type is None + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device.manufacturer == "LaMetric Inc." + assert device.name == "Frenck's LaMetric" + assert device.sw_version == "2.2.2" diff --git a/tests/components/lametric/test_services.py b/tests/components/lametric/test_services.py new file mode 100644 index 0000000000000000000000000000000000000000..4792db266f88cbed13adac2ab0086e04e783fbbe --- /dev/null +++ b/tests/components/lametric/test_services.py @@ -0,0 +1,211 @@ +"""Tests for the LaMetric services.""" +from unittest.mock import MagicMock + +from demetriek import ( + Chart, + LaMetricError, + Notification, + NotificationIconType, + NotificationPriority, + NotificationSound, + NotificationSoundCategory, + Simple, +) +import pytest + +from homeassistant.components.lametric.const import ( + CONF_CYCLES, + CONF_DATA, + CONF_ICON_TYPE, + CONF_MESSAGE, + CONF_PRIORITY, + CONF_SOUND, + DOMAIN, + SERVICE_CHART, + SERVICE_MESSAGE, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_ICON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_service_chart( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric chart service.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("button.frenck_s_lametric_next_app") + assert entry + assert entry.device_id + + await hass.services.async_call( + DOMAIN, + SERVICE_CHART, + { + CONF_DEVICE_ID: entry.device_id, + CONF_DATA: [1, 2, 3, 4, 5, 4, 3, 2, 1], + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.icon_type is NotificationIconType.NONE + assert notification.life_time is None + assert notification.model.cycles == 1 + assert notification.model.sound is None + assert notification.notification_id is None + assert notification.notification_type is None + assert notification.priority is NotificationPriority.INFO + + assert len(notification.model.frames) == 1 + frame = notification.model.frames[0] + assert type(frame) is Chart + assert frame.data == [1, 2, 3, 4, 5, 4, 3, 2, 1] + + await hass.services.async_call( + DOMAIN, + SERVICE_CHART, + { + CONF_DATA: [1, 2, 3, 4, 5, 4, 3, 2, 1], + CONF_DEVICE_ID: entry.device_id, + CONF_CYCLES: 3, + CONF_ICON_TYPE: "info", + CONF_PRIORITY: "critical", + CONF_SOUND: "cat", + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 2 + + notification: Notification = mock_lametric.notify.mock_calls[1][2]["notification"] + assert notification.icon_type is NotificationIconType.INFO + assert notification.life_time is None + assert notification.model.cycles == 3 + assert notification.model.sound is not None + assert notification.model.sound.category is NotificationSoundCategory.NOTIFICATIONS + assert notification.model.sound.sound is NotificationSound.CAT + assert notification.model.sound.repeat == 1 + assert notification.notification_id is None + assert notification.notification_type is None + assert notification.priority is NotificationPriority.CRITICAL + + assert len(notification.model.frames) == 1 + frame = notification.model.frames[0] + assert type(frame) is Chart + assert frame.data == [1, 2, 3, 4, 5, 4, 3, 2, 1] + + mock_lametric.notify.side_effect = LaMetricError + with pytest.raises( + HomeAssistantError, match="Could not send LaMetric notification" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CHART, + { + CONF_DEVICE_ID: entry.device_id, + CONF_DATA: [1, 2, 3, 4, 5], + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 3 + + +async def test_service_message( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric message service.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("button.frenck_s_lametric_next_app") + assert entry + assert entry.device_id + + await hass.services.async_call( + DOMAIN, + SERVICE_MESSAGE, + { + CONF_DEVICE_ID: entry.device_id, + CONF_MESSAGE: "Hi!", + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.icon_type is NotificationIconType.NONE + assert notification.life_time is None + assert notification.model.cycles == 1 + assert notification.model.sound is None + assert notification.notification_id is None + assert notification.notification_type is None + assert notification.priority is NotificationPriority.INFO + + assert len(notification.model.frames) == 1 + frame = notification.model.frames[0] + assert type(frame) is Simple + assert frame.icon is None + assert frame.text == "Hi!" + + await hass.services.async_call( + DOMAIN, + SERVICE_MESSAGE, + { + CONF_DEVICE_ID: entry.device_id, + CONF_MESSAGE: "Meow!", + CONF_CYCLES: 3, + CONF_ICON_TYPE: "info", + CONF_PRIORITY: "critical", + CONF_SOUND: "cat", + CONF_ICON: "6916", + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 2 + + notification: Notification = mock_lametric.notify.mock_calls[1][2]["notification"] + assert notification.icon_type is NotificationIconType.INFO + assert notification.life_time is None + assert notification.model.cycles == 3 + assert notification.model.sound is not None + assert notification.model.sound.category is NotificationSoundCategory.NOTIFICATIONS + assert notification.model.sound.sound is NotificationSound.CAT + assert notification.model.sound.repeat == 1 + assert notification.notification_id is None + assert notification.notification_type is None + assert notification.priority is NotificationPriority.CRITICAL + + assert len(notification.model.frames) == 1 + frame = notification.model.frames[0] + assert type(frame) is Simple + assert frame.icon == 6916 + assert frame.text == "Meow!" + + mock_lametric.notify.side_effect = LaMetricError + with pytest.raises( + HomeAssistantError, match="Could not send LaMetric notification" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_MESSAGE, + { + CONF_DEVICE_ID: entry.device_id, + CONF_MESSAGE: "Epic failure!", + }, + blocking=True, + ) + + assert len(mock_lametric.notify.mock_calls) == 3 diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py new file mode 100644 index 0000000000000000000000000000000000000000..7ed47fe463efea0729f201d1de946a582aedb42f --- /dev/null +++ b/tests/components/lametric/test_switch.py @@ -0,0 +1,153 @@ +"""Tests for the LaMetric switch platform.""" +from unittest.mock import MagicMock + +from demetriek import LaMetricConnectionError, LaMetricError +import pytest + +from homeassistant.components.lametric.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + STATE_OFF, + 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 homeassistant.helpers.entity import EntityCategory +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_bluetooth( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test the LaMetric Bluetooth control.""" + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + state = hass.states.get("switch.frenck_s_lametric_bluetooth") + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Bluetooth" + assert state.attributes.get(ATTR_ICON) == "mdi:bluetooth" + assert state.state == STATE_OFF + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry.entity_category is EntityCategory.CONFIG + assert entry.unique_id == "SA110405124500W00BS9-bluetooth" + + device = device_registry.async_get(entry.device_id) + assert device + assert device.configuration_url is None + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.entry_type is None + assert device.hw_version is None + assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} + assert device.manufacturer == "LaMetric Inc." + assert device.name == "Frenck's LaMetric" + assert device.sw_version == "2.2.2" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.frenck_s_lametric_bluetooth", + }, + blocking=True, + ) + + assert len(mock_lametric.bluetooth.mock_calls) == 1 + mock_lametric.bluetooth.assert_called_once_with(active=True) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "switch.frenck_s_lametric_bluetooth", + }, + blocking=True, + ) + + assert len(mock_lametric.bluetooth.mock_calls) == 2 + mock_lametric.bluetooth.assert_called_with(active=False) + + mock_lametric.device.return_value.bluetooth.available = False + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get("switch.frenck_s_lametric_bluetooth") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_switch_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test error handling of the LaMetric switches.""" + mock_lametric.bluetooth.side_effect = LaMetricError + + state = hass.states.get("switch.frenck_s_lametric_bluetooth") + assert state + assert state.state == STATE_OFF + + with pytest.raises( + HomeAssistantError, match="Invalid response from the LaMetric device" + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.frenck_s_lametric_bluetooth", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.frenck_s_lametric_bluetooth") + assert state + assert state.state == STATE_OFF + + +async def test_switch_connection_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test connection error handling of the LaMetric switches.""" + mock_lametric.bluetooth.side_effect = LaMetricConnectionError + + state = hass.states.get("switch.frenck_s_lametric_bluetooth") + assert state + assert state.state == STATE_OFF + + with pytest.raises( + HomeAssistantError, match="Error communicating with the LaMetric device" + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.frenck_s_lametric_bluetooth", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.frenck_s_lametric_bluetooth") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/led_ble/__init__.py b/tests/components/led_ble/__init__.py index 702b793f57af58cc63224b255ce71ec5bf2ada97..7f48ff7a0876548983c2dc44f2ff2fa02ad438ec 100644 --- a/tests/components/led_ble/__init__.py +++ b/tests/components/led_ble/__init__.py @@ -1,9 +1,10 @@ """Tests for the LED BLE Bluetooth integration.""" from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from tests.components.bluetooth import generate_advertisement_data + LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="Triones:F30200000152C", address="AA:BB:CC:DD:EE:FF", @@ -13,7 +14,7 @@ LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Triones:F30200000152C"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) @@ -27,7 +28,7 @@ UNSUPPORTED_LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="LEDnetWFF30200000152C"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) @@ -45,7 +46,7 @@ NOT_LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index 72a355877e1fa294f6010673fb406458c018eb95..774376e1a99af578611ea039f3c9f6adf43d4a63 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -91,6 +91,7 @@ def _mocked_bulb() -> Light: bulb.set_power = MockLifxCommand(bulb) bulb.set_color = MockLifxCommand(bulb) bulb.get_hostfirmware = MockLifxCommand(bulb) + bulb.get_wifiinfo = MockLifxCommand(bulb, signal=100) bulb.get_version = MockLifxCommand(bulb) bulb.set_waveform_optional = MockLifxCommand(bulb) bulb.product = 1 # LIFX Original 1000 @@ -123,12 +124,15 @@ def _mocked_clean_bulb() -> Light: bulb = _mocked_bulb() bulb.get_hev_cycle = MockLifxCommand(bulb) bulb.set_hev_cycle = MockLifxCommand(bulb) + bulb.get_hev_configuration = MockLifxCommand(bulb) + bulb.get_last_hev_cycle_result = MockLifxCommand(bulb) bulb.hev_cycle_configuration = {"duration": 7200, "indication": False} bulb.hev_cycle = { "duration": 7200, "remaining": 30, "last_power": False, } + bulb.last_hev_cycle_result = 0 bulb.product = 90 return bulb @@ -151,7 +155,23 @@ def _mocked_light_strip() -> Light: bulb.set_color_zones = MockLifxCommand(bulb) bulb.get_multizone_effect = MockLifxCommand(bulb) bulb.set_multizone_effect = MockLifxCommand(bulb) + bulb.get_extended_color_zones = MockLifxCommand(bulb) + bulb.set_extended_color_zones = MockLifxCommand(bulb) + return bulb + +def _mocked_tile() -> Light: + bulb = _mocked_bulb() + bulb.product = 55 # LIFX Tile + bulb.effect = {"effect": "OFF"} + bulb.get_tile_effect = MockLifxCommand(bulb) + bulb.set_tile_effect = MockLifxCommand(bulb) + return bulb + + +def _mocked_bulb_old_firmware() -> Light: + bulb = _mocked_bulb() + bulb.host_firmware_version = "2.77" return bulb diff --git a/tests/components/lifx/test_binary_sensor.py b/tests/components/lifx/test_binary_sensor.py index bb0b210704aecb2c06f0e21b7785df72d370adec..40db6ce1148fecb28a216dcc05a46489c136a1f7 100644 --- a/tests/components/lifx/test_binary_sensor.py +++ b/tests/components/lifx/test_binary_sensor.py @@ -1,4 +1,4 @@ -"""Test the lifx binary sensor platwform.""" +"""Test the lifx binary sensor platform.""" from __future__ import annotations from datetime import timedelta diff --git a/tests/components/lifx/test_diagnostics.py b/tests/components/lifx/test_diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..4eccd19634d5d938e16873b569888ee1d1da4b07 --- /dev/null +++ b/tests/components/lifx/test_diagnostics.py @@ -0,0 +1,385 @@ +"""Test LIFX diagnostics.""" +from homeassistant.components import lifx +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import ( + DEFAULT_ENTRY_TITLE, + IP_ADDRESS, + MAC_ADDRESS, + _mocked_bulb, + _mocked_clean_bulb, + _mocked_infrared_bulb, + _mocked_light_strip, + _patch_config_flow_try_connect, + _patch_device, + _patch_discovery, +) + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_bulb_diagnostics(hass: HomeAssistant, hass_client) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == { + "data": { + "brightness": 3, + "features": { + "buttons": False, + "chain": False, + "color": True, + "extended_multizone": False, + "hev": False, + "infrared": False, + "matrix": False, + "max_kelvin": 9000, + "min_kelvin": 2500, + "multizone": False, + "relays": False, + }, + "firmware": "3.00", + "hue": 1, + "kelvin": 4, + "power": 0, + "product_id": 1, + "saturation": 2, + "vendor": None, + }, + "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, + } + + +async def test_clean_bulb_diagnostics(hass: HomeAssistant, hass_client) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_clean_bulb() + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == { + "data": { + "brightness": 3, + "features": { + "buttons": False, + "chain": False, + "color": True, + "extended_multizone": False, + "hev": True, + "infrared": False, + "matrix": False, + "max_kelvin": 9000, + "min_kelvin": 1500, + "multizone": False, + "relays": False, + }, + "firmware": "3.00", + "hev": { + "hev_config": {"duration": 7200, "indication": False}, + "hev_cycle": {"duration": 7200, "last_power": False, "remaining": 30}, + "last_result": 0, + }, + "hue": 1, + "kelvin": 4, + "power": 0, + "product_id": 90, + "saturation": 2, + "vendor": None, + }, + "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, + } + + +async def test_infrared_bulb_diagnostics(hass: HomeAssistant, hass_client) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_infrared_bulb() + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == { + "data": { + "brightness": 3, + "features": { + "buttons": False, + "chain": False, + "color": True, + "extended_multizone": False, + "hev": False, + "infrared": True, + "matrix": False, + "max_kelvin": 9000, + "min_kelvin": 1500, + "multizone": False, + "relays": False, + }, + "firmware": "3.00", + "hue": 1, + "infrared": {"brightness": 65535}, + "kelvin": 4, + "power": 0, + "product_id": 29, + "saturation": 2, + "vendor": None, + }, + "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, + } + + +async def test_legacy_multizone_bulb_diagnostics( + hass: HomeAssistant, hass_client +) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_light_strip() + bulb.zones_count = 8 + bulb.color_zones = [ + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == { + "data": { + "brightness": 3, + "features": { + "buttons": False, + "chain": False, + "color": True, + "extended_multizone": False, + "hev": False, + "infrared": False, + "matrix": False, + "max_kelvin": 9000, + "min_kelvin": 2500, + "multizone": True, + "relays": False, + }, + "firmware": "3.00", + "hue": 1, + "kelvin": 4, + "power": 0, + "product_id": 31, + "saturation": 2, + "vendor": None, + "zones": { + "count": 8, + "state": { + "0": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "1": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "2": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "3": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "4": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + "5": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + "6": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + "7": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + }, + }, + }, + "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, + } + + +async def test_multizone_bulb_diagnostics(hass: HomeAssistant, hass_client) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_light_strip() + bulb.product = 38 + bulb.zones_count = 8 + bulb.color_zones = [ + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == { + "data": { + "brightness": 3, + "features": { + "buttons": False, + "chain": False, + "color": True, + "extended_multizone": True, + "hev": False, + "infrared": False, + "matrix": False, + "max_kelvin": 9000, + "min_ext_mz_firmware": 1532997580, + "min_ext_mz_firmware_components": [2, 77], + "min_kelvin": 1500, + "multizone": True, + "relays": False, + }, + "firmware": "3.00", + "hue": 1, + "kelvin": 4, + "power": 0, + "product_id": 38, + "saturation": 2, + "vendor": None, + "zones": { + "count": 8, + "state": { + "0": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "1": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "2": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "3": { + "brightness": 65535, + "hue": 54612, + "kelvin": 3500, + "saturation": 65535, + }, + "4": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + "5": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + "6": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + "7": { + "brightness": 65535, + "hue": 46420, + "kelvin": 3500, + "saturation": 65535, + }, + }, + }, + }, + "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, + } diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index e7c18989767a6b2d6e332cbfea65a94fa2c6bf5c..6fe63b14b6a5f458d632356c67d5c742c3099a22 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -12,14 +12,18 @@ from homeassistant.components.lifx.const import ATTR_POWER from homeassistant.components.lifx.light import ATTR_INFRARED, ATTR_ZONES from homeassistant.components.lifx.manager import ( ATTR_DIRECTION, + ATTR_PALETTE, ATTR_SPEED, + ATTR_THEME, SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_MORPH, SERVICE_EFFECT_MOVE, ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_RGB_COLOR, @@ -54,6 +58,7 @@ from . import ( _mocked_bulb_new_firmware, _mocked_clean_bulb, _mocked_light_strip, + _mocked_tile, _mocked_white_bulb, _patch_config_flow_try_connect, _patch_device, @@ -412,6 +417,383 @@ async def test_light_strip(hass: HomeAssistant) -> None: ) +async def test_extended_multizone_messages(hass: HomeAssistant) -> None: + """Test a light strip that supports extended multizone.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_light_strip() + bulb.product = 38 # LIFX Beam + bulb.power_level = 65535 + bulb.color = [65535, 65535, 65535, 3500] + bulb.color_zones = [(65535, 65535, 65535, 3500)] * 8 + bulb.zones_count = 8 + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 255 + assert attributes[ATTR_COLOR_MODE] == ColorMode.HS + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] + assert attributes[ATTR_HS_COLOR] == (360.0, 100.0) + assert attributes[ATTR_RGB_COLOR] == (255, 0, 0) + assert attributes[ATTR_XY_COLOR] == (0.701, 0.299) + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert bulb.set_power.calls[0][0][0] is False + bulb.set_power.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert bulb.set_power.calls[0][0][0] is True + bulb.set_power.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + bulb.set_power.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.color_zones = [ + (0, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.color_zones = [ + (0, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (255, 10, 30)}, + blocking=True, + ) + # always use a set_extended_color_zones + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.color_zones = [ + (0, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_XY_COLOR: (0.3, 0.7)}, + blocking=True, + ) + # Single color uses the fast path + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.color_zones = [ + (0, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + # always use set_extended_color_zones + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + await hass.services.async_call( + DOMAIN, + "set_state", + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGB_COLOR: (255, 255, 255), + ATTR_ZONES: [0, 2], + }, + blocking=True, + ) + # set a two zones + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (255, 255, 255), ATTR_ZONES: [3]}, + blocking=True, + ) + # set a one zone + assert len(bulb.set_power.calls) == 2 + assert len(bulb.get_color_zones.calls) == 0 + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + + bulb.get_color_zones.reset_mock() + bulb.set_power.reset_mock() + bulb.set_color_zones.reset_mock() + + bulb.set_extended_color_zones = MockFailingLifxCommand(bulb) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "set_state", + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGB_COLOR: (255, 255, 255), + ATTR_ZONES: [3], + }, + blocking=True, + ) + + bulb.set_extended_color_zones = MockLifxCommand(bulb) + bulb.get_extended_color_zones = MockFailingLifxCommand(bulb) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "set_state", + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGB_COLOR: (255, 255, 255), + ATTR_ZONES: [3], + }, + blocking=True, + ) + + +async def test_matrix_flame_morph_effects(hass: HomeAssistant) -> None: + """Test the firmware flame and morph effects on a matrix device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_tile() + bulb.power_level = 0 + bulb.color = [65535, 65535, 65535, 65535] + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_flame"}, + blocking=True, + ) + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_tile_effect.calls) == 1 + + call_dict = bulb.set_tile_effect.calls[0][1] + call_dict.pop("callb") + assert call_dict == { + "effect": 3, + "speed": 3, + "palette": [], + } + bulb.get_tile_effect.reset_mock() + bulb.set_tile_effect.reset_mock() + bulb.set_power.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_EFFECT_MORPH, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 4, ATTR_THEME: "autumn"}, + blocking=True, + ) + + bulb.power_level = 65535 + bulb.effect = { + "effect": "MORPH", + "speed": 4.0, + "palette": [ + (5643, 65535, 32768, 3500), + (15109, 65535, 32768, 3500), + (8920, 65535, 32768, 3500), + (10558, 65535, 32768, 3500), + ], + } + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_tile_effect.calls) == 1 + call_dict = bulb.set_tile_effect.calls[0][1] + call_dict.pop("callb") + assert call_dict == { + "effect": 2, + "speed": 4, + "palette": [ + (5643, 65535, 32768, 3500), + (15109, 65535, 32768, 3500), + (8920, 65535, 32768, 3500), + (10558, 65535, 32768, 3500), + ], + } + bulb.get_tile_effect.reset_mock() + bulb.set_tile_effect.reset_mock() + bulb.set_power.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_EFFECT_MORPH, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SPEED: 6, + ATTR_PALETTE: [ + (0, 100, 255, 3500), + (60, 100, 255, 3500), + (120, 100, 255, 3500), + (180, 100, 255, 3500), + (240, 100, 255, 3500), + (300, 100, 255, 3500), + ], + }, + blocking=True, + ) + + bulb.power_level = 65535 + bulb.effect = { + "effect": "MORPH", + "speed": 6, + "palette": [ + (0, 65535, 65535, 3500), + (10922, 65535, 65535, 3500), + (21845, 65535, 65535, 3500), + (32768, 65535, 65535, 3500), + (43690, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + ], + } + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_tile_effect.calls) == 1 + call_dict = bulb.set_tile_effect.calls[0][1] + call_dict.pop("callb") + assert call_dict == { + "effect": 2, + "speed": 6, + "palette": [ + (0, 65535, 65535, 3500), + (10922, 65535, 65535, 3500), + (21845, 65535, 65535, 3500), + (32768, 65535, 65535, 3500), + (43690, 65535, 65535, 3500), + (54613, 65535, 65535, 3500), + ], + } + bulb.get_tile_effect.reset_mock() + bulb.set_tile_effect.reset_mock() + bulb.set_power.reset_mock() + + async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: """Test the firmware move effect on a light strip.""" config_entry = MockConfigEntry( @@ -419,6 +801,7 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) bulb = _mocked_light_strip() + bulb.product = 38 bulb.power_level = 0 bulb.color = [65535, 65535, 65535, 65535] with _patch_discovery(device=bulb), _patch_config_flow_try_connect( @@ -446,6 +829,7 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: "speed": 3.0, "direction": 0, } + bulb.get_multizone_effect.reset_mock() bulb.set_multizone_effect.reset_mock() bulb.set_power.reset_mock() @@ -454,12 +838,17 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_EFFECT_MOVE, - {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 4.5, ATTR_DIRECTION: "left"}, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SPEED: 4.5, + ATTR_DIRECTION: "left", + ATTR_THEME: "sports", + }, blocking=True, ) bulb.power_level = 65535 - bulb.effect = {"name": "effect_move", "enable": 1} + bulb.effect = {"name": "MOVE", "speed": 4.5, "direction": "Left"} async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -467,6 +856,7 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: assert state.state == STATE_ON assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_extended_color_zones.calls) == 1 assert len(bulb.set_multizone_effect.calls) == 1 call_dict = bulb.set_multizone_effect.calls[0][1] call_dict.pop("callb") @@ -547,9 +937,9 @@ async def test_color_light_with_temp( ColorMode.COLOR_TEMP, ColorMode.HS, ] - assert attributes[ATTR_HS_COLOR] == (31.007, 6.862) - assert attributes[ATTR_RGB_COLOR] == (255, 246, 237) - assert attributes[ATTR_XY_COLOR] == (0.339, 0.338) + assert attributes[ATTR_HS_COLOR] == (30.754, 7.122) + assert attributes[ATTR_RGB_COLOR] == (255, 246, 236) + assert attributes[ATTR_XY_COLOR] == (0.34, 0.339) bulb.color = [65535, 65535, 65535, 65535] await hass.services.async_call( @@ -674,7 +1064,7 @@ async def test_white_bulb(hass: HomeAssistant) -> None: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ ColorMode.COLOR_TEMP, ] - assert attributes[ATTR_COLOR_TEMP] == 166 + assert attributes[ATTR_COLOR_TEMP_KELVIN] == 6000 await hass.services.async_call( LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) @@ -775,10 +1165,10 @@ async def test_white_light_fails(hass): await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 153}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6000}, blocking=True, ) - assert bulb.set_color.calls[0][0][0] == [1, 0, 3, 6535] + assert bulb.set_color.calls[0][0][0] == [1, 0, 3, 6000] bulb.set_color.reset_mock() diff --git a/tests/components/lifx/test_select.py b/tests/components/lifx/test_select.py index bc2d6f0fc1e804ad1065c22a006d2a175ee52dab..d190cbe6b10cde6892bfd5bbd4ec3cb620adacc7 100644 --- a/tests/components/lifx/test_select.py +++ b/tests/components/lifx/test_select.py @@ -17,6 +17,7 @@ from . import ( SERIAL, MockLifxCommand, _mocked_infrared_bulb, + _mocked_light_strip, _patch_config_flow_try_connect, _patch_device, _patch_discovery, @@ -25,6 +26,43 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed +async def test_theme_select(hass: HomeAssistant) -> None: + """Test selecting a theme.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_light_strip() + bulb.product = 38 + bulb.power_level = 0 + bulb.color = [0, 0, 65535, 3500] + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "select.my_bulb_theme" + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert not entity.disabled + + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: entity_id, "option": "intense"}, + blocking=True, + ) + + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_extended_color_zones.reset_mock() + + async def test_infrared_brightness(hass: HomeAssistant) -> None: """Test getting and setting infrared brightness.""" diff --git a/tests/components/lifx/test_sensor.py b/tests/components/lifx/test_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..a36e151849b472e48a73ebf6a01fa418a5973741 --- /dev/null +++ b/tests/components/lifx/test_sensor.py @@ -0,0 +1,131 @@ +"""Test the LIFX sensor platform.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.components import lifx +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + CONF_HOST, + SIGNAL_STRENGTH_DECIBELS, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from . import ( + DEFAULT_ENTRY_TITLE, + IP_ADDRESS, + MAC_ADDRESS, + _mocked_bulb, + _mocked_bulb_old_firmware, + _patch_config_flow_try_connect, + _patch_device, + _patch_discovery, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_rssi_sensor(hass: HomeAssistant) -> None: + """Test LIFX RSSI sensor entity.""" + + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "sensor.my_bulb_rssi" + entity_registry = er.async_get(hass) + + entry = entity_registry.entities.get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # Test enabling entity + updated_entry = entity_registry.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert updated_entry != entry + assert updated_entry.disabled is False + assert updated_entry.unit_of_measurement == SIGNAL_STRENGTH_DECIBELS_MILLIWATT + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=120)) + await hass.async_block_till_done() + + rssi = hass.states.get(entity_id) + assert ( + rssi.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS_MILLIWATT + ) + assert rssi.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH + assert rssi.attributes["state_class"] == SensorStateClass.MEASUREMENT + + +async def test_rssi_sensor_old_firmware(hass: HomeAssistant) -> None: + """Test LIFX RSSI sensor entity.""" + + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb_old_firmware() + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "sensor.my_bulb_rssi" + entity_registry = er.async_get(hass) + + entry = entity_registry.entities.get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # Test enabling entity + updated_entry = entity_registry.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert updated_entry != entry + assert updated_entry.disabled is False + assert updated_entry.unit_of_measurement == SIGNAL_STRENGTH_DECIBELS + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=120)) + await hass.async_block_till_done() + + rssi = hass.states.get(entity_id) + assert rssi.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS + assert rssi.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH + assert rssi.attributes["state_class"] == SensorStateClass.MEASUREMENT diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 1f21981340f9543e95ee3f0df52abd3d303e7f7b..bff46af29e926d83d7981678086b6090f587ead8 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -629,11 +629,33 @@ async def test_default_profiles_group( }, { light.ATTR_COLOR_TEMP: 600, + light.ATTR_COLOR_TEMP_KELVIN: 1666, light.ATTR_BRIGHTNESS: 11, light.ATTR_TRANSITION: 1, }, { light.ATTR_COLOR_TEMP: 600, + light.ATTR_COLOR_TEMP_KELVIN: 1666, + light.ATTR_BRIGHTNESS: 11, + light.ATTR_TRANSITION: 1, + }, + ), + ( + # Color temp in turn on params, color from profile ignored + { + light.ATTR_COLOR_TEMP_KELVIN: 6500, + light.ATTR_BRIGHTNESS: 11, + light.ATTR_TRANSITION: 1, + }, + { + light.ATTR_COLOR_TEMP: 153, + light.ATTR_COLOR_TEMP_KELVIN: 6500, + light.ATTR_BRIGHTNESS: 11, + light.ATTR_TRANSITION: 1, + }, + { + light.ATTR_COLOR_TEMP: 153, + light.ATTR_COLOR_TEMP_KELVIN: 6500, light.ATTR_BRIGHTNESS: 11, light.ATTR_TRANSITION: 1, }, @@ -1171,7 +1193,7 @@ async def test_light_backwards_compatibility_color_mode( entity2 = platform.ENTITIES[2] entity2.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR_TEMP - entity2.color_temp = 100 + entity2.color_temp_kelvin = 10000 entity3 = platform.ENTITIES[3] entity3.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR @@ -1182,7 +1204,7 @@ async def test_light_backwards_compatibility_color_mode( light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR | light.SUPPORT_COLOR_TEMP ) entity4.hs_color = (240, 100) - entity4.color_temp = 100 + entity4.color_temp_kelvin = 10000 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1440,7 +1462,7 @@ async def test_light_service_call_color_conversion(hass, enable_custom_integrati _, data = entity5.last_call("turn_on") assert data == {"brightness": 255, "rgbw_color": (0, 0, 0, 255)} _, data = entity6.last_call("turn_on") - # The midpoint the the white channels is warm, compensated by adding green + blue + # The midpoint of the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)} await hass.services.async_call( @@ -1843,7 +1865,7 @@ async def test_light_service_call_color_temp_emulation( blocking=True, ) _, data = entity0.last_call("turn_on") - assert data == {"brightness": 255, "color_temp": 200} + assert data == {"brightness": 255, "color_temp": 200, "color_temp_kelvin": 5000} _, data = entity1.last_call("turn_on") assert data == {"brightness": 255, "hs_color": (27.001, 19.243)} _, data = entity2.last_call("turn_on") @@ -1868,6 +1890,10 @@ async def test_light_service_call_color_temp_conversion( entity1 = platform.ENTITIES[1] entity1.supported_color_modes = {light.ColorMode.RGBWW} + assert entity1.min_mireds == 153 + assert entity1.max_mireds == 500 + assert entity1.min_color_temp_kelvin == 2000 + assert entity1.max_color_temp_kelvin == 6500 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1877,6 +1903,10 @@ async def test_light_service_call_color_temp_conversion( light.ColorMode.COLOR_TEMP, light.ColorMode.RGBWW, ] + assert state.attributes["min_mireds"] == 153 + assert state.attributes["max_mireds"] == 500 + assert state.attributes["min_color_temp_kelvin"] == 2000 + assert state.attributes["max_color_temp_kelvin"] == 6500 state = hass.states.get(entity1.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] @@ -1895,7 +1925,7 @@ async def test_light_service_call_color_temp_conversion( blocking=True, ) _, data = entity0.last_call("turn_on") - assert data == {"brightness": 255, "color_temp": 153} + assert data == {"brightness": 255, "color_temp": 153, "color_temp_kelvin": 6535} _, data = entity1.last_call("turn_on") # Home Assistant uses RGBCW so a mireds of 153 should be maximum cold at 100% brightness so 255 assert data == {"brightness": 255, "rgbww_color": (0, 0, 0, 255, 0)} @@ -1914,7 +1944,7 @@ async def test_light_service_call_color_temp_conversion( blocking=True, ) _, data = entity0.last_call("turn_on") - assert data == {"brightness": 128, "color_temp": 500} + assert data == {"brightness": 128, "color_temp": 500, "color_temp_kelvin": 2000} _, data = entity1.last_call("turn_on") # Home Assistant uses RGBCW so a mireds of 500 should be maximum warm at 50% brightness so 128 assert data == {"brightness": 128, "rgbww_color": (0, 0, 0, 0, 128)} @@ -1933,7 +1963,7 @@ async def test_light_service_call_color_temp_conversion( blocking=True, ) _, data = entity0.last_call("turn_on") - assert data == {"brightness": 255, "color_temp": 327} + assert data == {"brightness": 255, "color_temp": 327, "color_temp_kelvin": 3058} _, data = entity1.last_call("turn_on") # Home Assistant uses RGBCW so a mireds of 328 should be the midway point at 100% brightness so 127 (rounding), 128 assert data == {"brightness": 255, "rgbww_color": (0, 0, 0, 127, 128)} @@ -1952,7 +1982,7 @@ async def test_light_service_call_color_temp_conversion( blocking=True, ) _, data = entity0.last_call("turn_on") - assert data == {"brightness": 255, "color_temp": 240} + assert data == {"brightness": 255, "color_temp": 240, "color_temp_kelvin": 4166} _, data = entity1.last_call("turn_on") assert data == {"brightness": 255, "rgbww_color": (0, 0, 0, 191, 64)} @@ -1970,11 +2000,57 @@ async def test_light_service_call_color_temp_conversion( blocking=True, ) _, data = entity0.last_call("turn_on") - assert data == {"brightness": 255, "color_temp": 410} + assert data == {"brightness": 255, "color_temp": 410, "color_temp_kelvin": 2439} _, data = entity1.last_call("turn_on") assert data == {"brightness": 255, "rgbww_color": (0, 0, 0, 66, 189)} +async def test_light_mired_color_temp_conversion(hass, enable_custom_integrations): + """Test color temp conversion from K to legacy mired.""" + platform = getattr(hass.components, "test.light") + platform.init(empty=True) + + platform.ENTITIES.append(platform.MockLight("Test_rgbww_ct", STATE_ON)) + platform.ENTITIES.append(platform.MockLight("Test_rgbww", STATE_ON)) + + entity0 = platform.ENTITIES[0] + entity0.supported_color_modes = { + light.ColorMode.COLOR_TEMP, + } + entity0._attr_min_color_temp_kelvin = 1800 + entity0._attr_max_color_temp_kelvin = 6700 + + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + assert state.attributes["min_mireds"] == 149 + assert state.attributes["max_mireds"] == 555 + assert state.attributes["min_color_temp_kelvin"] == 1800 + assert state.attributes["max_color_temp_kelvin"] == 6700 + + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [ + entity0.entity_id, + ], + "brightness_pct": 100, + "color_temp_kelvin": 3500, + }, + blocking=True, + ) + _, data = entity0.last_call("turn_on") + assert data == {"brightness": 255, "color_temp": 285, "color_temp_kelvin": 3500} + + state = hass.states.get(entity0.entity_id) + assert state.attributes["color_mode"] == light.ColorMode.COLOR_TEMP + assert state.attributes["color_temp"] == 285 + assert state.attributes["color_temp_kelvin"] == 3500 + + async def test_light_service_call_white_mode(hass, enable_custom_integrations): """Test color_mode white in service calls.""" platform = getattr(hass.components, "test.light") diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index b6d26306317209b03a5f1ec11ef9df787f06379e..0ff092545f4b5cb64df31faf9258a8556207fa31 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -21,7 +21,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test light registered attributes to be excluded.""" await async_setup_component( hass, light.DOMAIN, {light.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 31ca16102509f91968486ab4bf97465b3d65872e..fb7ac21786768367c61667228a84fe284c21a6b0 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -58,7 +58,7 @@ EMPTY_CONFIG = logbook.CONFIG_SCHEMA({logbook.DOMAIN: {}}) @pytest.fixture -async def hass_(hass, recorder_mock): +async def hass_(recorder_mock, hass): """Set up things to be run when tests are started.""" assert await async_setup_component(hass, logbook.DOMAIN, EMPTY_CONFIG) return hass @@ -123,7 +123,7 @@ async def test_service_call_create_logbook_entry(hass_): assert last_call.data.get(logbook.ATTR_DOMAIN) == "logbook" -async def test_service_call_create_logbook_entry_invalid_entity_id(hass, recorder_mock): +async def test_service_call_create_logbook_entry_invalid_entity_id(recorder_mock, hass): """Test if service call create log book entry with an invalid entity id.""" await async_setup_component(hass, "logbook", {}) await hass.async_block_till_done() @@ -352,7 +352,7 @@ def create_state_changed_event_from_old_new( return LazyEventPartialState(row, {}) -async def test_logbook_view(hass, hass_client, recorder_mock): +async def test_logbook_view(recorder_mock, hass, hass_client): """Test the logbook view.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -361,7 +361,7 @@ async def test_logbook_view(hass, hass_client, recorder_mock): assert response.status == HTTPStatus.OK -async def test_logbook_view_invalid_start_date_time(hass, hass_client, recorder_mock): +async def test_logbook_view_invalid_start_date_time(recorder_mock, hass, hass_client): """Test the logbook view with an invalid date time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -370,7 +370,7 @@ async def test_logbook_view_invalid_start_date_time(hass, hass_client, recorder_ assert response.status == HTTPStatus.BAD_REQUEST -async def test_logbook_view_invalid_end_date_time(hass, hass_client, recorder_mock): +async def test_logbook_view_invalid_end_date_time(recorder_mock, hass, hass_client): """Test the logbook view.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -381,7 +381,7 @@ async def test_logbook_view_invalid_end_date_time(hass, hass_client, recorder_mo assert response.status == HTTPStatus.BAD_REQUEST -async def test_logbook_view_period_entity(hass, hass_client, recorder_mock, set_utc): +async def test_logbook_view_period_entity(recorder_mock, hass, hass_client, set_utc): """Test the logbook view with period and entity.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -462,7 +462,7 @@ async def test_logbook_view_period_entity(hass, hass_client, recorder_mock, set_ assert response_json[0]["entity_id"] == entity_id_test -async def test_logbook_describe_event(hass, hass_client, recorder_mock): +async def test_logbook_describe_event(recorder_mock, hass, hass_client): """Test teaching logbook about a new event.""" def _describe(event): @@ -498,7 +498,7 @@ async def test_logbook_describe_event(hass, hass_client, recorder_mock): assert event["domain"] == "test_domain" -async def test_exclude_described_event(hass, hass_client, recorder_mock): +async def test_exclude_described_event(recorder_mock, hass, hass_client): """Test exclusions of events that are described by another integration.""" name = "My Automation Rule" entity_id = "automation.excluded_rule" @@ -561,7 +561,7 @@ async def test_exclude_described_event(hass, hass_client, recorder_mock): assert event["entity_id"] == "automation.included_rule" -async def test_logbook_view_end_time_entity(hass, hass_client, recorder_mock): +async def test_logbook_view_end_time_entity(recorder_mock, hass, hass_client): """Test the logbook view with end_time and entity.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -616,7 +616,7 @@ async def test_logbook_view_end_time_entity(hass, hass_client, recorder_mock): assert response_json[0]["entity_id"] == entity_id_test -async def test_logbook_entity_filter_with_automations(hass, hass_client, recorder_mock): +async def test_logbook_entity_filter_with_automations(recorder_mock, hass, hass_client): """Test the logbook view with end_time and entity with automations and scripts.""" await asyncio.gather( *[ @@ -692,7 +692,7 @@ async def test_logbook_entity_filter_with_automations(hass, hass_client, recorde async def test_logbook_entity_no_longer_in_state_machine( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test the logbook view with an entity that hass been removed from the state machine.""" await async_setup_component(hass, "logbook", {}) @@ -730,7 +730,7 @@ async def test_logbook_entity_no_longer_in_state_machine( async def test_filter_continuous_sensor_values( - hass, hass_client, recorder_mock, set_utc + recorder_mock, hass, hass_client, set_utc ): """Test remove continuous sensor events from logbook.""" await async_setup_component(hass, "logbook", {}) @@ -770,7 +770,7 @@ async def test_filter_continuous_sensor_values( assert response_json[1]["entity_id"] == entity_id_third -async def test_exclude_new_entities(hass, hass_client, recorder_mock, set_utc): +async def test_exclude_new_entities(recorder_mock, hass, hass_client, set_utc): """Test if events are excluded on first update.""" await asyncio.gather( *[ @@ -807,7 +807,7 @@ async def test_exclude_new_entities(hass, hass_client, recorder_mock, set_utc): assert response_json[1]["message"] == "started" -async def test_exclude_removed_entities(hass, hass_client, recorder_mock, set_utc): +async def test_exclude_removed_entities(recorder_mock, hass, hass_client, set_utc): """Test if events are excluded on last update.""" await asyncio.gather( *[ @@ -851,7 +851,7 @@ async def test_exclude_removed_entities(hass, hass_client, recorder_mock, set_ut assert response_json[2]["entity_id"] == entity_id2 -async def test_exclude_attribute_changes(hass, hass_client, recorder_mock, set_utc): +async def test_exclude_attribute_changes(recorder_mock, hass, hass_client, set_utc): """Test if events of attribute changes are filtered.""" await asyncio.gather( *[ @@ -891,7 +891,7 @@ async def test_exclude_attribute_changes(hass, hass_client, recorder_mock, set_u assert response_json[2]["entity_id"] == "light.kitchen" -async def test_logbook_entity_context_id(hass, recorder_mock, hass_client): +async def test_logbook_entity_context_id(recorder_mock, hass, hass_client): """Test the logbook view with end_time and entity with automations and scripts.""" await asyncio.gather( *[ @@ -1042,7 +1042,7 @@ async def test_logbook_entity_context_id(hass, recorder_mock, hass_client): async def test_logbook_context_id_automation_script_started_manually( - hass, recorder_mock, hass_client + recorder_mock, hass, hass_client ): """Test the logbook populates context_ids for scripts and automations started manually.""" await asyncio.gather( @@ -1132,7 +1132,7 @@ async def test_logbook_context_id_automation_script_started_manually( assert json_dict[4]["context_domain"] == "script" -async def test_logbook_entity_context_parent_id(hass, hass_client, recorder_mock): +async def test_logbook_entity_context_parent_id(recorder_mock, hass, hass_client): """Test the logbook view links events via context parent_id.""" await asyncio.gather( *[ @@ -1311,7 +1311,7 @@ async def test_logbook_entity_context_parent_id(hass, hass_client, recorder_mock assert json_dict[8]["context_user_id"] == "485cacf93ef84d25a99ced3126b921d2" -async def test_logbook_context_from_template(hass, hass_client, recorder_mock): +async def test_logbook_context_from_template(recorder_mock, hass, hass_client): """Test the logbook view with end_time and entity with automations and scripts.""" await asyncio.gather( *[ @@ -1398,7 +1398,7 @@ async def test_logbook_context_from_template(hass, hass_client, recorder_mock): assert json_dict[5]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474" -async def test_logbook_(hass, hass_client, recorder_mock): +async def test_logbook_(recorder_mock, hass, hass_client): """Test the logbook view with a single entity and .""" await async_setup_component(hass, "logbook", {}) assert await async_setup_component( @@ -1467,7 +1467,7 @@ async def test_logbook_(hass, hass_client, recorder_mock): assert json_dict[1]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474" -async def test_logbook_many_entities_multiple_calls(hass, hass_client, recorder_mock): +async def test_logbook_many_entities_multiple_calls(recorder_mock, hass, hass_client): """Test the logbook view with a many entities called multiple times.""" await async_setup_component(hass, "logbook", {}) await async_setup_component(hass, "automation", {}) @@ -1537,7 +1537,7 @@ async def test_logbook_many_entities_multiple_calls(hass, hass_client, recorder_ assert len(json_dict) == 0 -async def test_custom_log_entry_discoverable_via_(hass, hass_client, recorder_mock): +async def test_custom_log_entry_discoverable_via_(recorder_mock, hass, hass_client): """Test if a custom log entry is later discoverable via .""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -1572,7 +1572,7 @@ async def test_custom_log_entry_discoverable_via_(hass, hass_client, recorder_mo assert json_dict[0]["entity_id"] == "switch.test_switch" -async def test_logbook_multiple_entities(hass, hass_client, recorder_mock): +async def test_logbook_multiple_entities(recorder_mock, hass, hass_client): """Test the logbook view with a multiple entities.""" await async_setup_component(hass, "logbook", {}) assert await async_setup_component( @@ -1696,7 +1696,7 @@ async def test_logbook_multiple_entities(hass, hass_client, recorder_mock): assert json_dict[3]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474" -async def test_logbook_invalid_entity(hass, hass_client, recorder_mock): +async def test_logbook_invalid_entity(recorder_mock, hass, hass_client): """Test the logbook view with requesting an invalid entity.""" await async_setup_component(hass, "logbook", {}) await hass.async_block_till_done() @@ -1714,7 +1714,7 @@ async def test_logbook_invalid_entity(hass, hass_client, recorder_mock): assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR -async def test_icon_and_state(hass, hass_client, recorder_mock): +async def test_icon_and_state(recorder_mock, hass, hass_client): """Test to ensure state and custom icons are returned.""" await asyncio.gather( *[ @@ -1757,7 +1757,7 @@ async def test_icon_and_state(hass, hass_client, recorder_mock): assert response_json[2]["state"] == STATE_OFF -async def test_fire_logbook_entries(hass, hass_client, recorder_mock): +async def test_fire_logbook_entries(recorder_mock, hass, hass_client): """Test many logbook entry calls.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -1793,7 +1793,7 @@ async def test_fire_logbook_entries(hass, hass_client, recorder_mock): assert len(response_json) == 11 -async def test_exclude_events_domain(hass, hass_client, recorder_mock): +async def test_exclude_events_domain(recorder_mock, hass, hass_client): """Test if events are filtered if domain is excluded in config.""" entity_id = "switch.bla" entity_id2 = "sensor.blu" @@ -1827,7 +1827,7 @@ async def test_exclude_events_domain(hass, hass_client, recorder_mock): _assert_entry(entries[1], name="blu", entity_id=entity_id2) -async def test_exclude_events_domain_glob(hass, hass_client, recorder_mock): +async def test_exclude_events_domain_glob(recorder_mock, hass, hass_client): """Test if events are filtered if domain or glob is excluded in config.""" entity_id = "switch.bla" entity_id2 = "sensor.blu" @@ -1870,7 +1870,7 @@ async def test_exclude_events_domain_glob(hass, hass_client, recorder_mock): _assert_entry(entries[1], name="blu", entity_id=entity_id2) -async def test_include_events_entity(hass, hass_client, recorder_mock): +async def test_include_events_entity(recorder_mock, hass, hass_client): """Test if events are filtered if entity is included in config.""" entity_id = "sensor.bla" entity_id2 = "sensor.blu" @@ -1910,7 +1910,7 @@ async def test_include_events_entity(hass, hass_client, recorder_mock): _assert_entry(entries[1], name="blu", entity_id=entity_id2) -async def test_exclude_events_entity(hass, hass_client, recorder_mock): +async def test_exclude_events_entity(recorder_mock, hass, hass_client): """Test if events are filtered if entity is excluded in config.""" entity_id = "sensor.bla" entity_id2 = "sensor.blu" @@ -1944,7 +1944,7 @@ async def test_exclude_events_entity(hass, hass_client, recorder_mock): _assert_entry(entries[1], name="blu", entity_id=entity_id2) -async def test_include_events_domain(hass, hass_client, recorder_mock): +async def test_include_events_domain(recorder_mock, hass, hass_client): """Test if events are filtered if domain is included in config.""" assert await async_setup_component(hass, "alexa", {}) entity_id = "switch.bla" @@ -1986,7 +1986,7 @@ async def test_include_events_domain(hass, hass_client, recorder_mock): _assert_entry(entries[2], name="blu", entity_id=entity_id2) -async def test_include_events_domain_glob(hass, hass_client, recorder_mock): +async def test_include_events_domain_glob(recorder_mock, hass, hass_client): """Test if events are filtered if domain or glob is included in config.""" assert await async_setup_component(hass, "alexa", {}) entity_id = "switch.bla" @@ -2043,7 +2043,7 @@ async def test_include_events_domain_glob(hass, hass_client, recorder_mock): _assert_entry(entries[3], name="included", entity_id=entity_id3) -async def test_include_exclude_events_no_globs(hass, hass_client, recorder_mock): +async def test_include_exclude_events_no_globs(recorder_mock, hass, hass_client): """Test if events are filtered if include and exclude is configured.""" entity_id = "switch.bla" entity_id2 = "sensor.blu" @@ -2100,7 +2100,7 @@ async def test_include_exclude_events_no_globs(hass, hass_client, recorder_mock) async def test_include_exclude_events_with_glob_filters( - hass, hass_client, recorder_mock + recorder_mock, hass, hass_client ): """Test if events are filtered if include and exclude is configured.""" entity_id = "switch.bla" @@ -2165,7 +2165,7 @@ async def test_include_exclude_events_with_glob_filters( _assert_entry(entries[6], name="included", entity_id=entity_id5, state="30") -async def test_empty_config(hass, hass_client, recorder_mock): +async def test_empty_config(recorder_mock, hass, hass_client): """Test we can handle an empty entity filter.""" entity_id = "sensor.blu" @@ -2197,7 +2197,7 @@ async def test_empty_config(hass, hass_client, recorder_mock): _assert_entry(entries[1], name="blu", entity_id=entity_id) -async def test_context_filter(hass, hass_client, recorder_mock): +async def test_context_filter(recorder_mock, hass, hass_client): """Test we can filter by context.""" assert await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -2269,7 +2269,7 @@ def _assert_entry( assert state == entry["state"] -async def test_get_events(hass, hass_ws_client, recorder_mock): +async def test_get_events(recorder_mock, hass, hass_ws_client): """Test logbook get_events.""" now = dt_util.utcnow() await asyncio.gather( @@ -2387,7 +2387,7 @@ async def test_get_events(hass, hass_ws_client, recorder_mock): assert isinstance(results[0]["when"], float) -async def test_get_events_future_start_time(hass, hass_ws_client, recorder_mock): +async def test_get_events_future_start_time(recorder_mock, hass, hass_ws_client): """Test get_events with a future start time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -2410,7 +2410,7 @@ async def test_get_events_future_start_time(hass, hass_ws_client, recorder_mock) assert len(results) == 0 -async def test_get_events_bad_start_time(hass, hass_ws_client, recorder_mock): +async def test_get_events_bad_start_time(recorder_mock, hass, hass_ws_client): """Test get_events bad start time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -2428,7 +2428,7 @@ async def test_get_events_bad_start_time(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_start_time" -async def test_get_events_bad_end_time(hass, hass_ws_client, recorder_mock): +async def test_get_events_bad_end_time(recorder_mock, hass, hass_ws_client): """Test get_events bad end time.""" now = dt_util.utcnow() await async_setup_component(hass, "logbook", {}) @@ -2448,7 +2448,7 @@ async def test_get_events_bad_end_time(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_end_time" -async def test_get_events_invalid_filters(hass, hass_ws_client, recorder_mock): +async def test_get_events_invalid_filters(recorder_mock, hass, hass_ws_client): """Test get_events invalid filters.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -2476,7 +2476,7 @@ async def test_get_events_invalid_filters(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_format" -async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): +async def test_get_events_with_device_ids(recorder_mock, hass, hass_ws_client): """Test logbook get_events for device ids.""" now = dt_util.utcnow() await asyncio.gather( @@ -2613,7 +2613,7 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): assert isinstance(results[3]["when"], float) -async def test_logbook_select_entities_context_id(hass, recorder_mock, hass_client): +async def test_logbook_select_entities_context_id(recorder_mock, hass, hass_client): """Test the logbook view with end_time and entity with automations and scripts.""" await asyncio.gather( *[ @@ -2746,7 +2746,7 @@ async def test_logbook_select_entities_context_id(hass, recorder_mock, hass_clie assert json_dict[3]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474" -async def test_get_events_with_context_state(hass, hass_ws_client, recorder_mock): +async def test_get_events_with_context_state(recorder_mock, hass, hass_ws_client): """Test logbook get_events with a context state.""" now = dt_util.utcnow() await asyncio.gather( @@ -2809,7 +2809,7 @@ async def test_get_events_with_context_state(hass, hass_ws_client, recorder_mock assert "context_event_type" not in results[3] -async def test_logbook_with_empty_config(hass, recorder_mock): +async def test_logbook_with_empty_config(recorder_mock, hass): """Test we handle a empty configuration.""" assert await async_setup_component( hass, @@ -2822,7 +2822,7 @@ async def test_logbook_with_empty_config(hass, recorder_mock): await hass.async_block_till_done() -async def test_logbook_with_non_iterable_entity_filter(hass, recorder_mock): +async def test_logbook_with_non_iterable_entity_filter(recorder_mock, hass): """Test we handle a non-iterable entity filter.""" assert await async_setup_component( hass, diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index a7bd28f0e4d98666c4cde013eb684977864ce5b4..fb0defca93c39e494177416ce1fba730696d39de 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -123,7 +123,7 @@ async def _async_mock_devices_with_logbook_platform(hass): return [device, device2] -async def test_get_events(hass, hass_ws_client, recorder_mock): +async def test_get_events(recorder_mock, hass, hass_ws_client): """Test logbook get_events.""" now = dt_util.utcnow() await asyncio.gather( @@ -241,7 +241,7 @@ async def test_get_events(hass, hass_ws_client, recorder_mock): assert isinstance(results[0]["when"], float) -async def test_get_events_entities_filtered_away(hass, hass_ws_client, recorder_mock): +async def test_get_events_entities_filtered_away(recorder_mock, hass, hass_ws_client): """Test logbook get_events all entities filtered away.""" now = dt_util.utcnow() await asyncio.gather( @@ -303,7 +303,7 @@ async def test_get_events_entities_filtered_away(hass, hass_ws_client, recorder_ assert len(results) == 0 -async def test_get_events_future_start_time(hass, hass_ws_client, recorder_mock): +async def test_get_events_future_start_time(recorder_mock, hass, hass_ws_client): """Test get_events with a future start time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -326,7 +326,7 @@ async def test_get_events_future_start_time(hass, hass_ws_client, recorder_mock) assert len(results) == 0 -async def test_get_events_bad_start_time(hass, hass_ws_client, recorder_mock): +async def test_get_events_bad_start_time(recorder_mock, hass, hass_ws_client): """Test get_events bad start time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -344,7 +344,7 @@ async def test_get_events_bad_start_time(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_start_time" -async def test_get_events_bad_end_time(hass, hass_ws_client, recorder_mock): +async def test_get_events_bad_end_time(recorder_mock, hass, hass_ws_client): """Test get_events bad end time.""" now = dt_util.utcnow() await async_setup_component(hass, "logbook", {}) @@ -364,7 +364,7 @@ async def test_get_events_bad_end_time(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_end_time" -async def test_get_events_invalid_filters(hass, hass_ws_client, recorder_mock): +async def test_get_events_invalid_filters(recorder_mock, hass, hass_ws_client): """Test get_events invalid filters.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -392,7 +392,7 @@ async def test_get_events_invalid_filters(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_format" -async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): +async def test_get_events_with_device_ids(recorder_mock, hass, hass_ws_client): """Test logbook get_events for device ids.""" now = dt_util.utcnow() await asyncio.gather( @@ -504,7 +504,7 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with excluded entities.""" now = dt_util.utcnow() @@ -528,7 +528,6 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( }, ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("light.exc", STATE_ON) hass.states.async_set("light.exc", STATE_OFF) @@ -544,6 +543,7 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} ) @@ -684,12 +684,12 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_included_entities( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with included entities.""" test_entities = ( @@ -722,7 +722,6 @@ async def test_subscribe_unsubscribe_logbook_stream_included_entities( }, ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) for entity_id in test_entities: hass.states.async_set(entity_id, STATE_ON) @@ -732,6 +731,7 @@ async def test_subscribe_unsubscribe_logbook_stream_included_entities( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} ) @@ -892,12 +892,12 @@ async def test_subscribe_unsubscribe_logbook_stream_included_entities( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_logbook_stream_excluded_entities_inherits_filters_from_recorder( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream inherts filters from recorder.""" now = dt_util.utcnow() @@ -926,7 +926,6 @@ async def test_logbook_stream_excluded_entities_inherits_filters_from_recorder( }, ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("light.exc", STATE_ON) hass.states.async_set("light.exc", STATE_OFF) @@ -943,6 +942,7 @@ async def test_logbook_stream_excluded_entities_inherits_filters_from_recorder( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} ) @@ -1083,12 +1083,12 @@ async def test_logbook_stream_excluded_entities_inherits_filters_from_recorder( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream.""" now = dt_util.utcnow() @@ -1100,7 +1100,6 @@ async def test_subscribe_unsubscribe_logbook_stream( ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -1109,6 +1108,7 @@ async def test_subscribe_unsubscribe_logbook_stream( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} ) @@ -1386,12 +1386,12 @@ async def test_subscribe_unsubscribe_logbook_stream( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_entities( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with specific entities.""" now = dt_util.utcnow() @@ -1403,7 +1403,6 @@ async def test_subscribe_unsubscribe_logbook_stream_entities( ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("light.small", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -1412,6 +1411,7 @@ async def test_subscribe_unsubscribe_logbook_stream_entities( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -1484,12 +1484,12 @@ async def test_subscribe_unsubscribe_logbook_stream_entities( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_entities_with_end_time( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with specific entities and an end_time.""" now = dt_util.utcnow() @@ -1501,7 +1501,6 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_with_end_time( ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("light.small", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -1510,6 +1509,7 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_with_end_time( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -1586,12 +1586,17 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_with_end_time( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) <= init_count + listeners = hass.bus.async_listeners() + # The async_fire_time_changed above triggers unsubscribe from + # homeassistant_final_write, don't worry about those + init_listeners.pop("homeassistant_final_write") + listeners.pop("homeassistant_final_write") + assert listeners == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_entities_past_only( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with specific entities in the past.""" now = dt_util.utcnow() @@ -1603,7 +1608,6 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_past_only( ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("light.small", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -1612,6 +1616,7 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_past_only( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -1654,12 +1659,12 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_past_only( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_big_query( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream and ask for a large time frame. @@ -1675,7 +1680,6 @@ async def test_subscribe_unsubscribe_logbook_stream_big_query( ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) four_days_ago = now - timedelta(days=4) five_days_ago = now - timedelta(days=5) @@ -1699,6 +1703,7 @@ async def test_subscribe_unsubscribe_logbook_stream_big_query( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -1754,12 +1759,12 @@ async def test_subscribe_unsubscribe_logbook_stream_big_query( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_device( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with a device.""" now = dt_util.utcnow() @@ -1774,10 +1779,10 @@ async def test_subscribe_unsubscribe_logbook_stream_device( device2 = devices[1] await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -1848,10 +1853,10 @@ async def test_subscribe_unsubscribe_logbook_stream_device( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners -async def test_event_stream_bad_start_time(hass, hass_ws_client, recorder_mock): +async def test_event_stream_bad_start_time(recorder_mock, hass, hass_ws_client): """Test event_stream bad start time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -1871,7 +1876,7 @@ async def test_event_stream_bad_start_time(hass, hass_ws_client, recorder_mock): @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_logbook_stream_match_multiple_entities( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test logbook stream with a described integration that uses multiple entities.""" now = dt_util.utcnow() @@ -1886,10 +1891,10 @@ async def test_logbook_stream_match_multiple_entities( hass.states.async_set(entity_id, STATE_ON) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -1963,10 +1968,10 @@ async def test_logbook_stream_match_multiple_entities( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners -async def test_event_stream_bad_end_time(hass, hass_ws_client, recorder_mock): +async def test_event_stream_bad_end_time(recorder_mock, hass, hass_ws_client): """Test event_stream bad end time.""" await async_setup_component(hass, "logbook", {}) await async_recorder_block_till_done(hass) @@ -1999,8 +2004,8 @@ async def test_event_stream_bad_end_time(hass, hass_ws_client, recorder_mock): async def test_live_stream_with_one_second_commit_interval( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, hass_ws_client, ): """Test the recorder with a 1s commit interval.""" @@ -2017,7 +2022,6 @@ async def test_live_stream_with_one_second_commit_interval( device = devices[0] await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.bus.async_fire("mock_event", {"device_id": device.id, "message": "1"}) @@ -2030,6 +2034,7 @@ async def test_live_stream_with_one_second_commit_interval( hass.bus.async_fire("mock_event", {"device_id": device.id, "message": "3"}) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( { "id": 7, @@ -2086,11 +2091,11 @@ async def test_live_stream_with_one_second_commit_interval( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) -async def test_subscribe_disconnected(hass, recorder_mock, hass_ws_client): +async def test_subscribe_disconnected(recorder_mock, hass, hass_ws_client): """Test subscribe/unsubscribe logbook stream gets disconnected.""" now = dt_util.utcnow() await asyncio.gather( @@ -2101,7 +2106,6 @@ async def test_subscribe_disconnected(hass, recorder_mock, hass_ws_client): ) await async_wait_recording_done(hass) - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("light.small", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -2109,6 +2113,9 @@ async def test_subscribe_disconnected(hass, recorder_mock, hass_ws_client): await hass.async_block_till_done() await async_wait_recording_done(hass) + # We will compare event subscriptions after closing the websocket connection, + # count the listeners before setting it up + init_listeners = hass.bus.async_listeners() websocket_client = await hass_ws_client() await websocket_client.send_json( { @@ -2139,11 +2146,11 @@ async def test_subscribe_disconnected(hass, recorder_mock, hass_ws_client): await hass.async_block_till_done() # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) -async def test_stream_consumer_stop_processing(hass, recorder_mock, hass_ws_client): +async def test_stream_consumer_stop_processing(recorder_mock, hass, hass_ws_client): """Test we unsubscribe if the stream consumer fails or is canceled.""" now = dt_util.utcnow() await asyncio.gather( @@ -2153,7 +2160,7 @@ async def test_stream_consumer_stop_processing(hass, recorder_mock, hass_ws_clie ] ) await async_wait_recording_done(hass) - init_count = sum(hass.bus.async_listeners().values()) + init_listeners = hass.bus.async_listeners() hass.states.async_set("light.small", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -2162,7 +2169,7 @@ async def test_stream_consumer_stop_processing(hass, recorder_mock, hass_ws_clie await async_wait_recording_done(hass) websocket_client = await hass_ws_client() - after_ws_created_count = sum(hass.bus.async_listeners().values()) + after_ws_created_listeners = hass.bus.async_listeners() with patch.object(websocket_api, "MAX_PENDING_LOGBOOK_EVENTS", 5), patch.object( websocket_api, "_async_events_consumer" @@ -2182,7 +2189,7 @@ async def test_stream_consumer_stop_processing(hass, recorder_mock, hass_ws_clie assert msg["type"] == TYPE_RESULT assert msg["success"] - assert sum(hass.bus.async_listeners().values()) != init_count + assert hass.bus.async_listeners() != init_listeners for _ in range(5): hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -2190,13 +2197,13 @@ async def test_stream_consumer_stop_processing(hass, recorder_mock, hass_ws_clie # Check our listener got unsubscribed because # the queue got full and the overload safety tripped - assert sum(hass.bus.async_listeners().values()) == after_ws_created_count + assert hass.bus.async_listeners() == after_ws_created_listeners await websocket_client.close() - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) -async def test_recorder_is_far_behind(hass, recorder_mock, hass_ws_client, caplog): +async def test_recorder_is_far_behind(recorder_mock, hass, hass_ws_client, caplog): """Test we still start live streaming if the recorder is far behind.""" now = dt_util.utcnow() await asyncio.gather( @@ -2272,7 +2279,7 @@ async def test_recorder_is_far_behind(hass, recorder_mock, hass_ws_client, caplo @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_all_entities_are_continuous( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with entities that are always filtered.""" now = dt_util.utcnow() @@ -2294,7 +2301,9 @@ async def test_subscribe_all_entities_are_continuous( hass.states.async_set("counter.any", state) hass.states.async_set("proximity.any", state) - init_count = sum(hass.bus.async_listeners().values()) + # We will compare event subscriptions after closing the websocket connection, + # count the listeners before setting it up + init_listeners = hass.bus.async_listeners() _cycle_entities() await async_wait_recording_done(hass) @@ -2323,12 +2332,12 @@ async def test_subscribe_all_entities_are_continuous( await hass.async_block_till_done() # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_all_entities_have_uom_multiple( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test logbook stream with specific request for multiple entities that are always filtered.""" now = dt_util.utcnow() @@ -2348,7 +2357,9 @@ async def test_subscribe_all_entities_have_uom_multiple( entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"} ) - init_count = sum(hass.bus.async_listeners().values()) + # We will compare event subscriptions after closing the websocket connection, + # count the listeners before setting it up + init_listeners = hass.bus.async_listeners() _cycle_entities() await async_wait_recording_done(hass) @@ -2378,14 +2389,14 @@ async def test_subscribe_all_entities_have_uom_multiple( await hass.async_block_till_done() # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_entities_some_have_uom_multiple( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): - """Test logbook stream with uom filtered entities and non-fitlered entities.""" + """Test logbook stream with uom filtered entities and non-filtered entities.""" now = dt_util.utcnow() await asyncio.gather( *[ @@ -2407,7 +2418,9 @@ async def test_subscribe_entities_some_have_uom_multiple( for state in (STATE_ON, STATE_OFF): hass.states.async_set(entity_id, state) - init_count = sum(hass.bus.async_listeners().values()) + # We will compare event subscriptions after closing the websocket connection, + # count the listeners before setting it up + init_listeners = hass.bus.async_listeners() _cycle_entities() await async_wait_recording_done(hass) @@ -2481,12 +2494,12 @@ async def test_subscribe_entities_some_have_uom_multiple( await hass.async_block_till_done() # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_logbook_stream_ignores_forced_updates( - hass, recorder_mock, hass_ws_client + recorder_mock, hass, hass_ws_client ): """Test logbook live stream ignores forced updates.""" now = dt_util.utcnow() @@ -2498,7 +2511,6 @@ async def test_logbook_stream_ignores_forced_updates( ) await hass.async_block_till_done() - init_count = sum(hass.bus.async_listeners().values()) hass.states.async_set("binary_sensor.is_light", STATE_ON) hass.states.async_set("binary_sensor.is_light", STATE_OFF) @@ -2507,6 +2519,7 @@ async def test_logbook_stream_ignores_forced_updates( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() await websocket_client.send_json( {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} ) @@ -2595,12 +2608,12 @@ async def test_logbook_stream_ignores_forced_updates( assert msg["success"] # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners @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 + recorder_mock, hass, hass_ws_client ): """Test subscribe/unsubscribe logbook stream with entities that are always filtered and a device.""" now = dt_util.utcnow() @@ -2628,7 +2641,9 @@ async def test_subscribe_all_entities_are_continuous_with_device( 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()) + # We will compare event subscriptions after closing the websocket connection, + # count the listeners before setting it up + init_listeners = hass.bus.async_listeners() _create_events() await async_wait_recording_done(hass) @@ -2688,4 +2703,4 @@ async def test_subscribe_all_entities_are_continuous_with_device( await hass.async_block_till_done() # Check our listener got unsubscribed - assert sum(hass.bus.async_listeners().values()) == init_count + assert hass.bus.async_listeners() == init_listeners diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py index 91ddfe26fb56da5dcebfe250c11b58a7575ddcc5..5e3db30ad5b36860e8d5192fd5fccd2a2f6d1871 100644 --- a/tests/components/lutron_caseta/__init__.py +++ b/tests/components/lutron_caseta/__init__.py @@ -105,11 +105,11 @@ class MockBridge: """Initialize MockBridge instance with configured mock connectivity.""" self.can_connect = can_connect self.is_currently_connected = False - self.buttons = {} - self.areas = {} + self.areas = self.load_areas() self.occupancy_groups = {} self.scenes = self.get_scenes() self.devices = self.load_devices() + self.buttons = self.load_buttons() async def connect(self): """Connect the mock bridge.""" @@ -119,14 +119,36 @@ class MockBridge: def add_subscriber(self, device_id: str, callback_): """Mock a listener to be notified of state changes.""" + def add_button_subscriber(self, button_id: str, callback_): + """Mock a listener for button presses.""" + def is_connected(self): """Return whether the mock bridge is connected.""" return self.is_currently_connected + def load_areas(self): + """Loak mock areas into self.areas.""" + return { + "3": {"id": "3", "name": "House", "parent_id": None}, + "898": {"id": "898", "name": "Basement", "parent_id": "3"}, + "822": {"id": "822", "name": "Bedroom", "parent_id": "898"}, + "910": {"id": "910", "name": "Bathroom", "parent_id": "898"}, + "1024": {"id": "1024", "name": "Master Bedroom", "parent_id": "3"}, + "1025": {"id": "1025", "name": "Kitchen", "parent_id": "3"}, + "1026": {"id": "1026", "name": "Dining Room", "parent_id": "3"}, + "1205": {"id": "1205", "name": "Hallway", "parent_id": "3"}, + } + def load_devices(self): """Load mock devices into self.devices.""" return { - "1": {"serial": 1234, "name": "bridge", "model": "model", "type": "type"}, + "1": { + "serial": 1234, + "name": "bridge", + "model": "model", + "type": "type", + "area": "1205", + }, "801": { "device_id": "801", "current_state": 100, @@ -138,6 +160,7 @@ class MockBridge: "model": None, "serial": None, "tilt": None, + "area": "822", }, "802": { "device_id": "802", @@ -150,6 +173,7 @@ class MockBridge: "model": None, "serial": None, "tilt": None, + "area": "822", }, "803": { "device_id": "803", @@ -162,6 +186,7 @@ class MockBridge: "model": None, "serial": None, "tilt": None, + "area": "910", }, "804": { "device_id": "804", @@ -174,6 +199,7 @@ class MockBridge: "model": None, "serial": None, "tilt": None, + "area": "1024", }, "901": { "device_id": "901", @@ -186,6 +212,65 @@ class MockBridge: "model": None, "serial": 5442321, "tilt": None, + "area": "1025", + }, + "9": { + "device_id": "9", + "current_state": -1, + "fan_speed": None, + "tilt": None, + "zone": None, + "name": "Dining Room_Pico", + "button_groups": ["4"], + "occupancy_sensors": None, + "type": "Pico3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 68551522, + "device_name": "Pico", + "area": "1026", + }, + "1355": { + "device_id": "1355", + "current_state": -1, + "fan_speed": None, + "zone": None, + "name": "Hallway_Main Stairs Position 1 Keypad", + "button_groups": ["1363"], + "type": "SunnataKeypad", + "model": "RRST-W3RL-XX", + "serial": 66286451, + "control_station_name": "Main Stairs", + "device_name": "Position 1", + "area": "1205", + }, + } + + def load_buttons(self): + """Load mock buttons into self.buttons.""" + return { + "111": { + "device_id": "111", + "current_state": "Release", + "button_number": 1, + "name": "Dining Room_Pico", + "type": "Pico3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 68551522, + "parent_device": "9", + }, + "1372": { + "device_id": "1372", + "current_state": "Release", + "button_number": 3, + "button_group": "1363", + "name": "Hallway_Main Stairs Position 1 Keypad", + "type": "SunnataKeypad", + "model": "RRST-W3RL-XX", + "serial": 66286451, + "button_name": "Kitchen Pendants", + "button_led": "1362", + "device_name": "Kitchen Pendants", + "parent_device": "1355", }, } @@ -228,6 +313,13 @@ class MockBridge: """Return scenes on the bridge.""" return {} + def get_buttons(self): + """Will return all known buttons connected to the bridge/processor.""" + return self.buttons + + def tap_button(self, button_id: str): + """Mock a button press and release message for the given button ID.""" + async def close(self): """Close the mock bridge connection.""" self.is_currently_connected = False diff --git a/tests/components/lutron_caseta/test_button.py b/tests/components/lutron_caseta/test_button.py new file mode 100644 index 0000000000000000000000000000000000000000..68742e5bae3aec02e86dcdbd866f42809ef67126 --- /dev/null +++ b/tests/components/lutron_caseta/test_button.py @@ -0,0 +1,50 @@ +"""Tests for the Lutron Caseta integration.""" + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MockBridge, async_setup_integration + + +async def test_button_unique_id(hass: HomeAssistant) -> None: + """Test a button unique id.""" + await async_setup_integration(hass, MockBridge) + + ra3_button_entity_id = ( + "button.hallway_main_stairs_position_1_keypad_kitchen_pendants" + ) + caseta_button_entity_id = "button.dining_room_pico_stop" + + entity_registry = er.async_get(hass) + + # Assert that Caseta buttons will have the bridge serial hash and the zone id as the uniqueID + assert entity_registry.async_get(ra3_button_entity_id).unique_id == "000004d2_1372" + assert ( + entity_registry.async_get(caseta_button_entity_id).unique_id == "000004d2_111" + ) + + +async def test_button_press(hass: HomeAssistant) -> None: + """Test a button press.""" + await async_setup_integration(hass, MockBridge) + + ra3_button_entity_id = ( + "button.hallway_main_stairs_position_1_keypad_kitchen_pendants" + ) + + state = hass.states.get(ra3_button_entity_id) + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ra3_button_entity_id}, + blocking=False, + ) + await hass.async_block_till_done() + + state = hass.states.get(ra3_button_entity_id) + assert state diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index 161f5cf357f58f217df8e1179272b1f3c45d3f92..a1558822619adda7eb7f8b2448a0ca1aaa9b1d28 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -1,5 +1,5 @@ """The tests for Lutron Caséta device triggers.""" -from unittest.mock import MagicMock +from unittest.mock import patch import pytest @@ -14,16 +14,26 @@ from homeassistant.components.lutron_caseta import ( ) from homeassistant.components.lutron_caseta.const import ( ATTR_LEAP_BUTTON_NUMBER, + CONF_CA_CERTS, + CONF_CERTFILE, + CONF_KEYFILE, 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.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_HOST, + CONF_PLATFORM, + CONF_TYPE, +) from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component +from . import MockBridge + from tests.common import ( MockConfigEntry, assert_lists_same, @@ -34,7 +44,8 @@ from tests.common import ( MOCK_BUTTON_DEVICES = [ { - "Name": "Back Hall Pico", + "device_id": "9", + "Name": "Dining Room_Pico", "ID": 2, "Area": {"Name": "Back Hall"}, "Buttons": [ @@ -44,13 +55,14 @@ MOCK_BUTTON_DEVICES = [ {"Number": 5}, {"Number": 6}, ], - "leap_name": "Back Hall_Back Hall Pico", + "leap_name": "Dining Room_Pico", "type": "Pico3ButtonRaiseLower", "model": "PJ2-3BRL-GXX-X01", - "serial": 43845548, + "serial": 68551522, }, { - "Name": "Front Steps Sunnata Keypad", + "device_id": "1355", + "Name": "Main Stairs Position 1 Keypad", "ID": 3, "Area": {"Name": "Front Steps"}, "Buttons": [ @@ -63,7 +75,26 @@ MOCK_BUTTON_DEVICES = [ "leap_name": "Front Steps_Front Steps Sunnata Keypad", "type": "SunnataKeypad", "model": "RRST-W4B-XX", - "serial": 43845547, + "serial": 66286451, + }, + { + "device_id": "786", + "Name": "Example Homeowner Keypad", + "ID": 4, + "Area": {"Name": "Front Steps"}, + "Buttons": [ + {"Number": 12}, + {"Number": 13}, + {"Number": 14}, + {"Number": 15}, + {"Number": 16}, + {"Number": 17}, + {"Number": 18}, + ], + "leap_name": "Front Steps_Example Homeowner Keypad", + "type": "HomeownerKeypad", + "model": "Homeowner Keypad", + "serial": "1234_786", }, ] @@ -80,36 +111,36 @@ def device_reg(hass): return mock_device_registry(hass) -async def _async_setup_lutron_with_picos(hass, device_reg): +async def _async_setup_lutron_with_picos(hass): """Setups a lutron bridge with picos.""" - await async_setup_component(hass, DOMAIN, {}) - - config_entry = MockConfigEntry(domain=DOMAIN, data={}) - config_entry.add_to_hass(hass) - dr_button_devices = {} - - for device in MOCK_BUTTON_DEVICES: - dr_device = device_reg.async_get_or_create( - name=device["leap_name"], - manufacturer=MANUFACTURER, - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, device["serial"])}, - model=f"{device['model']} ({device[CONF_TYPE]})", - ) - dr_button_devices[dr_device.id] = device - - hass.data[DOMAIN][config_entry.entry_id] = LutronCasetaData( - MagicMock(), MagicMock(), dr_button_devices + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_KEYFILE: "", + CONF_CERTFILE: "", + CONF_CA_CERTS: "", + }, + unique_id="abc", ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.lutron_caseta.Smartbridge.create_tls", + return_value=MockBridge(can_connect=True), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + 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) + config_entry_id = await _async_setup_lutron_with_picos(hass) data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] - dr_button_devices = data.button_devices - device_id = list(dr_button_devices)[0] + keypads = data.keypad_data.keypads + device_id = keypads[list(keypads)[0]]["dr_device_id"] expected_triggers = [ { @@ -143,7 +174,7 @@ async def test_get_triggers(hass, device_reg): async def test_get_triggers_for_invalid_device_id(hass, device_reg): """Test error raised for invalid lutron device_id.""" - config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) + config_entry_id = await _async_setup_lutron_with_picos(hass) invalid_device = device_reg.async_get_or_create( config_entry_id=config_entry_id, @@ -159,7 +190,7 @@ async def test_get_triggers_for_invalid_device_id(hass, device_reg): async def test_get_triggers_for_non_button_device(hass, device_reg): """Test error raised for invalid lutron device_id.""" - config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) + config_entry_id = await _async_setup_lutron_with_picos(hass) invalid_device = device_reg.async_get_or_create( config_entry_id=config_entry_id, @@ -173,13 +204,27 @@ async def test_get_triggers_for_non_button_device(hass, device_reg): assert triggers == [] +async def test_none_serial_keypad(hass, device_reg): + """Test serial assignment for keypads without serials.""" + config_entry_id = await _async_setup_lutron_with_picos(hass) + + keypad_device = device_reg.async_get_or_create( + config_entry_id=config_entry_id, + identifiers={(DOMAIN, "1234_786")}, + ) + + assert keypad_device is not None + + async def test_if_fires_on_button_event(hass, calls, device_reg): """Test for press trigger firing.""" - await _async_setup_lutron_with_picos(hass, device_reg) + await _async_setup_lutron_with_picos(hass) + device = MOCK_BUTTON_DEVICES[0] dr = device_registry.async_get(hass) dr_device = dr.async_get_device(identifiers={(DOMAIN, device["serial"])}) device_id = dr_device.id + assert await async_setup_component( hass, automation.DOMAIN, @@ -219,7 +264,7 @@ async def test_if_fires_on_button_event(hass, calls, device_reg): async def test_if_fires_on_button_event_without_lip(hass, calls, device_reg): """Test for press trigger firing on a device that does not support lip.""" - await _async_setup_lutron_with_picos(hass, device_reg) + await _async_setup_lutron_with_picos(hass) device = MOCK_BUTTON_DEVICES[1] dr = device_registry.async_get(hass) dr_device = dr.async_get_device(identifiers={(DOMAIN, device["serial"])}) @@ -235,7 +280,7 @@ async def test_if_fires_on_button_event_without_lip(hass, calls, device_reg): CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, CONF_TYPE: "press", - CONF_SUBTYPE: "button_1", + CONF_SUBTYPE: "Kitchen Pendants", }, "action": { "service": "test.automation", @@ -249,7 +294,7 @@ async def test_if_fires_on_button_event_without_lip(hass, calls, device_reg): message = { ATTR_SERIAL: device.get("serial"), ATTR_TYPE: device.get("type"), - ATTR_LEAP_BUTTON_NUMBER: 1, + ATTR_LEAP_BUTTON_NUMBER: 3, ATTR_DEVICE_NAME: device["Name"], ATTR_AREA_NAME: device.get("Area", {}).get("Name"), ATTR_ACTION: "press", @@ -302,12 +347,13 @@ async def test_validate_trigger_config_no_device(hass, calls, device_reg): 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) + config_entry_id = await _async_setup_lutron_with_picos(hass) 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" + keypads = data.keypad_data.keypads + lutron_device_id = list(keypads)[0] + keypad = keypads[lutron_device_id] + device_id = keypad["dr_device_id"] + keypad["type"] = "unknown" assert await async_setup_component( hass, @@ -346,10 +392,13 @@ 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) + config_entry_id = await _async_setup_lutron_with_picos(hass) data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] - dr_button_devices = data.button_devices - device_id = list(dr_button_devices)[0] + keypads = data.keypad_data.keypads + lutron_device_id = list(keypads)[0] + keypad = keypads[lutron_device_id] + device_id = keypad["dr_device_id"] + assert await async_setup_component( hass, automation.DOMAIN, diff --git a/tests/components/lutron_caseta/test_diagnostics.py b/tests/components/lutron_caseta/test_diagnostics.py index 42fc1dac5c19ff43d03211ac5842e2c8d2fd3ab6..98a5b26e8090c0166c600e3a631b6132517bdd79 100644 --- a/tests/components/lutron_caseta/test_diagnostics.py +++ b/tests/components/lutron_caseta/test_diagnostics.py @@ -1,6 +1,6 @@ """Test the Lutron Caseta diagnostics.""" -from unittest.mock import patch +from unittest.mock import ANY, patch from homeassistant.components.lutron_caseta import DOMAIN from homeassistant.components.lutron_caseta.const import ( @@ -39,15 +39,50 @@ async def test_diagnostics(hass, hass_client) -> None: diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag == { - "data": { - "areas": {}, - "buttons": {}, + "bridge_data": { + "areas": { + "3": {"id": "3", "name": "House", "parent_id": None}, + "898": {"id": "898", "name": "Basement", "parent_id": "3"}, + "822": {"id": "822", "name": "Bedroom", "parent_id": "898"}, + "910": {"id": "910", "name": "Bathroom", "parent_id": "898"}, + "1024": {"id": "1024", "name": "Master Bedroom", "parent_id": "3"}, + "1025": {"id": "1025", "name": "Kitchen", "parent_id": "3"}, + "1026": {"id": "1026", "name": "Dining Room", "parent_id": "3"}, + "1205": {"id": "1205", "name": "Hallway", "parent_id": "3"}, + }, + "buttons": { + "111": { + "device_id": "111", + "current_state": "Release", + "button_number": 1, + "name": "Dining Room_Pico", + "type": "Pico3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 68551522, + "parent_device": "9", + }, + "1372": { + "device_id": "1372", + "current_state": "Release", + "button_number": 3, + "button_group": "1363", + "name": "Hallway_Main Stairs Position 1 Keypad", + "type": "SunnataKeypad", + "model": "RRST-W3RL-XX", + "serial": 66286451, + "button_name": "Kitchen Pendants", + "button_led": "1362", + "device_name": "Kitchen Pendants", + "parent_device": "1355", + }, + }, "devices": { "1": { "model": "model", "name": "bridge", "serial": 1234, "type": "type", + "area": "1205", }, "801": { "device_id": "801", @@ -60,6 +95,7 @@ async def test_diagnostics(hass, hass_client) -> None: "model": None, "serial": None, "tilt": None, + "area": "822", }, "802": { "device_id": "802", @@ -72,6 +108,7 @@ async def test_diagnostics(hass, hass_client) -> None: "model": None, "serial": None, "tilt": None, + "area": "822", }, "803": { "device_id": "803", @@ -84,6 +121,7 @@ async def test_diagnostics(hass, hass_client) -> None: "model": None, "serial": None, "tilt": None, + "area": "910", }, "804": { "device_id": "804", @@ -96,6 +134,7 @@ async def test_diagnostics(hass, hass_client) -> None: "model": None, "serial": None, "tilt": None, + "area": "1024", }, "901": { "device_id": "901", @@ -108,6 +147,36 @@ async def test_diagnostics(hass, hass_client) -> None: "model": None, "serial": 5442321, "tilt": None, + "area": "1025", + }, + "9": { + "device_id": "9", + "current_state": -1, + "fan_speed": None, + "tilt": None, + "zone": None, + "name": "Dining Room_Pico", + "button_groups": ["4"], + "occupancy_sensors": None, + "type": "Pico3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 68551522, + "device_name": "Pico", + "area": "1026", + }, + "1355": { + "device_id": "1355", + "current_state": -1, + "fan_speed": None, + "zone": None, + "name": "Hallway_Main Stairs Position 1 Keypad", + "button_groups": ["1363"], + "type": "SunnataKeypad", + "model": "RRST-W3RL-XX", + "serial": 66286451, + "control_station_name": "Main Stairs", + "device_name": "Position 1", + "area": "1205", }, }, "occupancy_groups": {}, @@ -117,4 +186,66 @@ async def test_diagnostics(hass, hass_client) -> None: "data": {"ca_certs": "", "certfile": "", "host": "1.1.1.1", "keyfile": ""}, "title": "Mock Title", }, + "integration_data": { + "keypad_button_names_to_leap": { + "1355": {"Kitchen Pendants": 3}, + "9": {"Stop": 1}, + }, + "keypad_buttons": { + "111": { + "button_name": "Stop", + "leap_button_number": 1, + "led_device_id": None, + "lutron_device_id": "111", + "parent_keypad": "9", + }, + "1372": { + "button_name": "Kitchen " "Pendants", + "leap_button_number": 3, + "led_device_id": "1362", + "lutron_device_id": "1372", + "parent_keypad": "1355", + }, + }, + "keypads": { + "1355": { + "area_id": "1205", + "area_name": "Hallway", + "buttons": ["1372"], + "device_info": { + "identifiers": [["lutron_caseta", 66286451]], + "manufacturer": "Lutron " "Electronics " "Co., " "Inc", + "model": "RRST-W3RL-XX " "(SunnataKeypad)", + "name": "Hallway " "Main " "Stairs " "Position 1 " "Keypad", + "suggested_area": "Hallway", + "via_device": ["lutron_caseta", 1234], + }, + "dr_device_id": ANY, + "lutron_device_id": "1355", + "model": "RRST-W3RL-XX", + "name": "Main Stairs Position 1 " "Keypad", + "serial": 66286451, + "type": "SunnataKeypad", + }, + "9": { + "area_id": "1026", + "area_name": "Dining Room", + "buttons": ["111"], + "device_info": { + "identifiers": [["lutron_caseta", 68551522]], + "manufacturer": "Lutron " "Electronics " "Co., " "Inc", + "model": "PJ2-3BRL-GXX-X01 " "(Pico3ButtonRaiseLower)", + "name": "Dining Room " "Pico", + "suggested_area": "Dining " "Room", + "via_device": ["lutron_caseta", 1234], + }, + "dr_device_id": ANY, + "lutron_device_id": "9", + "model": "PJ2-3BRL-GXX-X01", + "name": "Pico", + "serial": 68551522, + "type": "Pico3ButtonRaiseLower", + }, + }, + }, } diff --git a/tests/components/lutron_caseta/test_logbook.py b/tests/components/lutron_caseta/test_logbook.py index 3a202eadf58cce4f1ffcca4a4658e3aab563e76b..c189238d9df3c64df2b4e0e18309768781474287 100644 --- a/tests/components/lutron_caseta/test_logbook.py +++ b/tests/components/lutron_caseta/test_logbook.py @@ -15,6 +15,7 @@ from homeassistant.components.lutron_caseta.const import ( DOMAIN, LUTRON_CASETA_BUTTON_EVENT, ) +from homeassistant.components.lutron_caseta.models import LutronCasetaData from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST from homeassistant.setup import async_setup_component @@ -49,25 +50,30 @@ async def test_humanify_lutron_caseta_button_event(hass): await hass.async_block_till_done() + data: LutronCasetaData = hass.data[DOMAIN][config_entry.entry_id] + keypads = data.keypad_data.keypads + keypad = keypads["9"] + dr_device_id = keypad["dr_device_id"] + (event1,) = mock_humanify( hass, [ MockRow( LUTRON_CASETA_BUTTON_EVENT, { - ATTR_SERIAL: "123", - ATTR_DEVICE_ID: "1234", + ATTR_SERIAL: "68551522", + ATTR_DEVICE_ID: dr_device_id, ATTR_TYPE: "Pico3ButtonRaiseLower", - ATTR_LEAP_BUTTON_NUMBER: 3, - ATTR_BUTTON_NUMBER: 3, + ATTR_LEAP_BUTTON_NUMBER: 1, + ATTR_BUTTON_NUMBER: 1, ATTR_DEVICE_NAME: "Pico", - ATTR_AREA_NAME: "Living Room", + ATTR_AREA_NAME: "Dining Room", ATTR_ACTION: "press", }, ), ], ) - assert event1["name"] == "Living Room Pico" + assert event1["name"] == "Dining Room Pico" assert event1["domain"] == DOMAIN - assert event1["message"] == "press raise" + assert event1["message"] == "press stop" diff --git a/tests/components/mazda/test_sensor.py b/tests/components/mazda/test_sensor.py index 2284101fa84ff8b80a3567ebb84026b9132a46ab..4484e6b7783d07e5cfbe0ad26c6a8e626ee46c85 100644 --- a/tests/components/mazda/test_sensor.py +++ b/tests/components/mazda/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( PRESSURE_PSI, ) from homeassistant.helpers import entity_registry as er -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import init_integration @@ -132,7 +132,7 @@ async def test_sensors(hass): async def test_sensors_imperial_units(hass): """Test that the sensors work properly with imperial units.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await init_integration(hass) diff --git a/tests/components/media_player/test_recorder.py b/tests/components/media_player/test_recorder.py index dd1329be81e85cc0b549824f08f9a11c3722eaf2..34397a58c0cad5c0126894c6b9cfbb0bc5559005 100644 --- a/tests/components/media_player/test_recorder.py +++ b/tests/components/media_player/test_recorder.py @@ -22,7 +22,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test media_player registered attributes to be excluded.""" await async_setup_component( hass, media_player.DOMAIN, {media_player.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index 1b5af1f8abf8ec67eb73055d23ae8dadefff26f6..c06933b7404531486a537e52660a6154fa07ff64 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -5,7 +5,6 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from melnor_bluetooth.device import Device from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak @@ -14,6 +13,7 @@ from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +from tests.components.bluetooth import generate_advertisement_data FAKE_ADDRESS_1 = "FAKE-ADDRESS-1" FAKE_ADDRESS_2 = "FAKE-ADDRESS-2" @@ -30,7 +30,7 @@ FAKE_SERVICE_INFO_1 = BluetoothServiceInfoBleak( service_data={}, source="local", device=BLEDevice(FAKE_ADDRESS_1, None), - advertisement=AdvertisementData(local_name=""), + advertisement=generate_advertisement_data(local_name=""), time=0, connectable=True, ) @@ -46,7 +46,7 @@ FAKE_SERVICE_INFO_2 = BluetoothServiceInfoBleak( service_data={}, source="local", device=BLEDevice(FAKE_ADDRESS_2, None), - advertisement=AdvertisementData(local_name=""), + advertisement=generate_advertisement_data(local_name=""), time=0, connectable=True, ) diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 6a71806cea9a4f29dd6540ca7245f9bf504ceb1f..6a2945c406bdf28b8535822de57f7aca685ed2ad 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -162,3 +162,99 @@ async def test_wrong_credentials(hass, auth_error): CONF_USERNAME: "invalid_auth", CONF_PASSWORD: "invalid_auth", } + + +async def test_reauth_success(hass, api): + """Test we can reauth.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=DEMO_USER_INPUT, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=DEMO_USER_INPUT, + ) + + 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 reauth fails due to wrong password.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=DEMO_USER_INPUT, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=DEMO_USER_INPUT, + ) + + 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 reauth failed due to connection error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=DEMO_USER_INPUT, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=DEMO_USER_INPUT, + ) + + 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/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index 47435dfbaf3dafdaf3cadf3dfa0313e97c7fd6b6..4819cc31a9b73067a15c57524345f7090318ec0a 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, ) +import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -63,6 +64,7 @@ async def test_min_sensor(hass): "name": "test_min", "type": "min", "entity_ids": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id", } } @@ -81,6 +83,10 @@ async def test_min_sensor(hass): assert entity_ids[2] == state.attributes.get("min_entity_id") assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + entity_reg = er.async_get(hass) + entity = entity_reg.async_get("sensor.test_min") + assert entity.unique_id == "very_unique_id" + async def test_max_sensor(hass): """Test the max sensor.""" diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index 930fb522c4c5cdfe684c676cfb7eea704e881c35..13e11db3effbcc1ad6f27516b9248134069a8908 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -13,14 +13,14 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM @pytest.mark.parametrize( "unit_system, state_unit, state1, state2", ( (METRIC_SYSTEM, TEMP_CELSIUS, "100", "123"), - (IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, "212", "253"), + (US_CUSTOMARY_SYSTEM, TEMP_FAHRENHEIT, "212", "253"), ), ) async def test_sensor( @@ -124,9 +124,9 @@ async def test_sensor( "unique_id, unit_system, state_unit, state1, state2", ( ("battery_temperature", METRIC_SYSTEM, TEMP_CELSIUS, "100", "123"), - ("battery_temperature", IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, "212", "253"), + ("battery_temperature", US_CUSTOMARY_SYSTEM, TEMP_FAHRENHEIT, "212", "253"), # The unique_id doesn't match that of the mobile app's battery temperature sensor - ("battery_temp", IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, "212", "123"), + ("battery_temp", US_CUSTOMARY_SYSTEM, TEMP_FAHRENHEIT, "212", "123"), ), ) async def test_sensor_migration( diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index bca633215978e0b690fa2de4b4ceb03fa56904aa..777d284e20f3c44dca4bff7456169f173e50b456 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -5,6 +5,7 @@ from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, CALL_TYPE_DISCRETE, + CALL_TYPE_REGISTER_HOLDING, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_SLAVE_COUNT, @@ -81,6 +82,15 @@ async def test_config_binary_sensor(hass, mock_modbus): }, ], }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + }, + ], + }, ], ) @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 942f6997c214da9fc35d75d197911d8438ad1d44..e554160d5bb5dae3e475eac048d366cbb5b5d264 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -1,10 +1,18 @@ """The tests for the Modbus climate component.""" import pytest -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + HVACMode, +) from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, + CONF_HVAC_ONOFF_REGISTER, CONF_LAZY_ERROR, CONF_TARGET_TEMP, MODBUS_DOMAIN, @@ -52,6 +60,40 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") } ], }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 12, + } + ], + }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 12, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_HVAC_MODE_VALUES: { + HVACMode.OFF.value: 0, + HVACMode.HEAT.value: 1, + HVACMode.COOL.value: 2, + HVACMode.HEAT_COOL.value: 3, + HVACMode.DRY.value: 4, + HVACMode.FAN_ONLY.value: 5, + HVACMode.AUTO.value: 6, + }, + }, + } + ], + }, ], ) async def test_config_climate(hass, mock_modbus): @@ -59,6 +101,62 @@ async def test_config_climate(hass, mock_modbus): assert CLIMATE_DOMAIN in hass.config.components +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_HVAC_MODE_VALUES: { + HVACMode.OFF.value: 0, + HVACMode.HEAT.value: 1, + HVACMode.COOL.value: 2, + HVACMode.HEAT_COOL.value: 3, + }, + }, + } + ], + }, + ], +) +async def test_config_hvac_mode_register(hass, mock_modbus): + """Run configuration test for mode register.""" + state = hass.states.get(ENTITY_ID) + assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] + assert HVACMode.HEAT in state.attributes[ATTR_HVAC_MODES] + assert HVACMode.COOL in state.attributes[ATTR_HVAC_MODES] + assert HVACMode.HEAT_COOL in state.attributes[ATTR_HVAC_MODES] + + +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 11, + } + ], + }, + ], +) +async def test_config_hvac_onoff_register(hass, mock_modbus): + """Run configuration test for On/Off register.""" + state = hass.states.get(ENTITY_ID) + assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] + assert HVACMode.AUTO in state.attributes[ATTR_HVAC_MODES] + + @pytest.mark.parametrize( "do_config", [ @@ -90,28 +188,93 @@ async def test_temperature_climate(hass, expected, mock_do_cycle): @pytest.mark.parametrize( - "do_config", + "do_config,result,register_words", [ - { - CONF_CLIMATES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_SLAVE: 10, - CONF_SCAN_INTERVAL: 0, - CONF_DATA_TYPE: DataType.INT32, - } - ] - }, + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 0, + HVACMode.HEAT.value: 1, + HVACMode.DRY.value: 2, + }, + }, + }, + ] + }, + HVACMode.COOL, + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 0, + HVACMode.HEAT.value: 1, + HVACMode.DRY.value: 2, + }, + }, + }, + ] + }, + HVACMode.HEAT, + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 0, + HVACMode.HEAT.value: 2, + HVACMode.DRY.value: 3, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + }, + ] + }, + HVACMode.OFF, + [0x00], + ), ], ) -async def test_service_climate_update(hass, mock_modbus, mock_ha): +async def test_service_climate_update( + hass, mock_modbus, mock_ha, result, register_words +): """Run test for service homeassistant.update_entity.""" + mock_modbus.read_holding_registers.return_value = ReadResult(register_words) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) - assert hass.states.get(ENTITY_ID).state == "auto" + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == result @pytest.mark.parametrize( @@ -195,6 +358,68 @@ async def test_service_climate_set_temperature( ) +@pytest.mark.parametrize( + "hvac_mode, result, do_config", + [ + ( + HVACMode.COOL, + [0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 1, + HVACMode.HEAT.value: 2, + }, + }, + } + ] + }, + ), + ( + HVACMode.HEAT, + [0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 1, + HVACMode.HEAT.value: 2, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + } + ] + }, + ), + ], +) +async def test_service_set_mode(hass, hvac_mode, result, mock_modbus, mock_ha): + """Test set mode.""" + mock_modbus.read_holding_registers.return_value = ReadResult(result) + await hass.services.async_call( + CLIMATE_DOMAIN, + "set_hvac_mode", + { + "entity_id": ENTITY_ID, + ATTR_HVAC_MODE: hvac_mode, + }, + blocking=True, + ) + + test_value = State(ENTITY_ID, 35) test_value.attributes = {ATTR_TEMPERATURE: 37} diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index 6fe38ccf7a120eff0fc4723486ed73427304e9df..edb987e5664257072938d1284f3dc7526d1f8769 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -75,7 +75,11 @@ async def test_hassio_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), + data=HassioServiceInfo( + config={"addon": "motionEye", "url": TEST_URL}, + name="motionEye", + slug="motioneye", + ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -351,7 +355,11 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), + data=HassioServiceInfo( + config={"addon": "motionEye", "url": TEST_URL}, + name="motionEye", + slug="motioneye", + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result.get("type") == data_entry_flow.FlowResultType.ABORT @@ -366,7 +374,11 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), + data=HassioServiceInfo( + config={"addon": "motionEye", "url": TEST_URL}, + name="motionEye", + slug="motioneye", + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result.get("type") == data_entry_flow.FlowResultType.ABORT @@ -382,7 +394,11 @@ async def test_hassio_abort_if_already_in_progress(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, - data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), + data=HassioServiceInfo( + config={"addon": "motionEye", "url": TEST_URL}, + name="motionEye", + slug="motioneye", + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result2.get("type") == data_entry_flow.FlowResultType.ABORT @@ -394,7 +410,11 @@ async def test_hassio_clean_up_on_user_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data=HassioServiceInfo(config={"addon": "motionEye", "url": TEST_URL}), + data=HassioServiceInfo( + config={"addon": "motionEye", "url": TEST_URL}, + name="motionEye", + slug="motioneye", + ), context={"source": config_entries.SOURCE_HASSIO}, ) assert result.get("type") == data_entry_flow.FlowResultType.FORM diff --git a/tests/components/motioneye/test_media_source.py b/tests/components/motioneye/test_media_source.py index 541db872b51b155bab0a91d6cc8897fafd1450ec..b4803c52a6d5ba606c8b29bab9c8e0919c13b48a 100644 --- a/tests/components/motioneye/test_media_source.py +++ b/tests/components/motioneye/test_media_source.py @@ -251,13 +251,13 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None: "thumbnail": None, "children": [ { - "title": "00-26-22.mp4", + "title": "00-02-27.mp4", "media_class": "video", "media_content_type": "video/mp4", "media_content_id": ( "media-source://motioneye" f"/74565ad414754616000674c87bdc876c#{device.id}#movies#" - "/2021-04-25/00-26-22.mp4" + "/2021-04-25/00-02-27.mp4" ), "can_play": True, "can_expand": False, @@ -265,13 +265,13 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None: "children_media_class": None, }, { - "title": "00-36-49.mp4", + "title": "00-26-22.mp4", "media_class": "video", "media_content_type": "video/mp4", "media_content_id": ( "media-source://motioneye" f"/74565ad414754616000674c87bdc876c#{device.id}#movies#" - "/2021-04-25/00-36-49.mp4" + "/2021-04-25/00-26-22.mp4" ), "can_play": True, "can_expand": False, @@ -279,13 +279,13 @@ async def test_async_browse_media_success(hass: HomeAssistant) -> None: "children_media_class": None, }, { - "title": "00-02-27.mp4", + "title": "00-36-49.mp4", "media_class": "video", "media_content_type": "video/mp4", "media_content_id": ( "media-source://motioneye" f"/74565ad414754616000674c87bdc876c#{device.id}#movies#" - "/2021-04-25/00-02-27.mp4" + "/2021-04-25/00-36-49.mp4" ), "can_play": True, "can_expand": False, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 5d67b34db5d15ea845c4774a2b3376503d7807eb..eef728664aab7e5d5075df0e02c9cd95276745de 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -1,5 +1,8 @@ """Test config flow.""" +from random import getrandbits +from ssl import SSLError from unittest.mock import AsyncMock, patch +from uuid import uuid4 import pytest import voluptuous as vol @@ -13,6 +16,9 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +MOCK_CLIENT_CERT = b"## mock client certificate file ##" +MOCK_CLIENT_KEY = b"## mock key file ##" + @pytest.fixture(autouse=True) def mock_finish_setup(): @@ -23,6 +29,43 @@ def mock_finish_setup(): yield mock_finish +@pytest.fixture +def mock_client_cert_check_fail(): + """Mock the client certificate check.""" + with patch( + "homeassistant.components.mqtt.config_flow.load_pem_x509_certificate", + side_effect=ValueError, + ) as mock_cert_check: + yield mock_cert_check + + +@pytest.fixture +def mock_client_key_check_fail(): + """Mock the client key file check.""" + with patch( + "homeassistant.components.mqtt.config_flow.load_pem_private_key", + side_effect=ValueError, + ) as mock_key_check: + yield mock_key_check + + +@pytest.fixture +def mock_ssl_context(): + """Mock the SSL context used to load the cert chain and to load verify locations.""" + with patch( + "homeassistant.components.mqtt.config_flow.SSLContext" + ) as mock_context, patch( + "homeassistant.components.mqtt.config_flow.load_pem_private_key" + ) as mock_key_check, patch( + "homeassistant.components.mqtt.config_flow.load_pem_x509_certificate" + ) as mock_cert_check: + yield { + "context": mock_context, + "load_pem_x509_certificate": mock_cert_check, + "load_pem_private_key": mock_key_check, + } + + @pytest.fixture def mock_reload_after_entry_update(): """Mock out the reload after updating the entry.""" @@ -84,6 +127,45 @@ def mock_try_connection_time_out(): yield mock_client() +@pytest.fixture +def mock_process_uploaded_file(tmp_path): + """Mock upload certificate files.""" + file_id_ca = str(uuid4()) + file_id_cert = str(uuid4()) + file_id_key = str(uuid4()) + + def _mock_process_uploaded_file(hass, file_id): + if file_id == file_id_ca: + with open(tmp_path / "ca.crt", "wb") as cafile: + cafile.write(b"## mock CA certificate file ##") + return tmp_path / "ca.crt" + elif file_id == file_id_cert: + with open(tmp_path / "client.crt", "wb") as certfile: + certfile.write(b"## mock client certificate file ##") + return tmp_path / "client.crt" + elif file_id == file_id_key: + with open(tmp_path / "client.key", "wb") as keyfile: + keyfile.write(b"## mock key file ##") + return tmp_path / "client.key" + else: + assert False + + with patch( + "homeassistant.components.mqtt.config_flow.process_uploaded_file", + side_effect=_mock_process_uploaded_file, + ) as mock_upload, patch( + # Patch temp dir name to avoid tests fail running in parallel + "homeassistant.components.mqtt.util.TEMP_DIR_NAME", + "home-assistant-mqtt" + f"-{getrandbits(10):03x}", + ): + mock_upload.file_id = { + mqtt.CONF_CERTIFICATE: file_id_ca, + mqtt.CONF_CLIENT_CERT: file_id_cert, + mqtt.CONF_CLIENT_KEY: file_id_key, + } + yield mock_upload + + async def test_user_connection_works( hass, mock_try_connection, mock_finish_setup, mqtt_client_mock ): @@ -96,7 +178,7 @@ async def test_user_connection_works( assert result["type"] == "form" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"broker": "127.0.0.1"} + result["flow_id"], {"broker": "127.0.0.1", "advanced_options": False} ) assert result["type"] == "create_entry" @@ -104,6 +186,7 @@ async def test_user_connection_works( "broker": "127.0.0.1", "port": 1883, "discovery": True, + "discovery_prefix": "homeassistant", } # Check we tried the connection assert len(mock_try_connection.mock_calls) == 1 @@ -184,19 +267,15 @@ async def test_manual_config_set( "broker": "127.0.0.1", "port": 1883, "discovery": True, + "discovery_prefix": "homeassistant", } # Check we tried the connection, with precedence for config entry settings mock_try_connection.assert_called_once_with( { - "broker": "bla", - "keepalive": 60, - "discovery_prefix": "homeassistant", - "protocol": "3.1.1", + "broker": "127.0.0.1", + "port": 1883, + "discovery": True, }, - "127.0.0.1", - 1883, - None, - None, ) # Check config entry got setup assert len(mock_finish_setup.mock_calls) == 1 @@ -240,7 +319,9 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: "host": "mock-mosquitto", "port": "1883", "protocol": "3.1.1", - } + }, + name="Mosquitto", + slug="mosquitto", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -264,7 +345,9 @@ async def test_hassio_confirm(hass, mock_try_connection_success, mock_finish_set "password": "mock-pass", "protocol": "3.1.1", # Set by the addon's discovery, ignored by HA "ssl": False, # Set by the addon's discovery, ignored by HA - } + }, + name="Mock Addon", + slug="mosquitto", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -284,6 +367,7 @@ async def test_hassio_confirm(hass, mock_try_connection_success, mock_finish_set "username": "mock-user", "password": "mock-pass", "discovery": True, + "discovery_prefix": "homeassistant", } # Check we tried the connection assert len(mock_try_connection_success.mock_calls) @@ -291,6 +375,46 @@ async def test_hassio_confirm(hass, mock_try_connection_success, mock_finish_set assert len(mock_finish_setup.mock_calls) == 1 +async def test_hassio_cannot_connect( + hass, mock_try_connection_time_out, mock_finish_setup +): + """Test a config flow is aborted when a connection was not successful.""" + mock_try_connection.return_value = True + + result = await hass.config_entries.flow.async_init( + "mqtt", + data=HassioServiceInfo( + config={ + "addon": "Mock Addon", + "host": "mock-broker", + "port": 1883, + "username": "mock-user", + "password": "mock-pass", + "protocol": "3.1.1", # Set by the addon's discovery, ignored by HA + "ssl": False, # Set by the addon's discovery, ignored by HA + }, + name="Mock Addon", + slug="mosquitto", + ), + context={"source": config_entries.SOURCE_HASSIO}, + ) + assert result["type"] == "form" + assert result["step_id"] == "hassio_confirm" + assert result["description_placeholders"] == {"addon": "Mock Addon"} + + mock_try_connection_time_out.reset_mock() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"discovery": True} + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == "cannot_connect" + # Check we tried the connection + assert len(mock_try_connection_time_out.mock_calls) + # Check config entry got setup + assert len(mock_finish_setup.mock_calls) == 0 + + @patch( "homeassistant.config.async_hass_config_yaml", AsyncMock(return_value={}), @@ -299,7 +423,7 @@ async def test_option_flow( hass, mqtt_mock_entry_no_yaml_config, mock_try_connection, - mock_reload_after_entry_update, + caplog, ): """Test config flow options.""" mqtt_mock = await mqtt_mock_entry_no_yaml_config() @@ -335,6 +459,7 @@ async def test_option_flow( result["flow_id"], user_input={ mqtt.CONF_DISCOVERY: True, + "discovery_prefix": "homeassistant", "birth_enable": True, "birth_topic": "ha_state/online", "birth_payload": "online", @@ -355,6 +480,7 @@ async def test_option_flow( mqtt.CONF_USERNAME: "user", mqtt.CONF_PASSWORD: "pass", mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", mqtt.CONF_BIRTH_MESSAGE: { mqtt.ATTR_TOPIC: "ha_state/online", mqtt.ATTR_PAYLOAD: "online", @@ -372,7 +498,164 @@ async def test_option_flow( await hass.async_block_till_done() assert config_entry.title == "another-broker" # assert that the entry was reloaded with the new config - assert mock_reload_after_entry_update.call_count == 1 + assert ( + "<Event call_service[L]: domain=mqtt, service=reload, service_data=>" + in caplog.text + ) + + +@pytest.mark.parametrize( + "test_error", + [ + "bad_certificate", + "bad_client_cert", + "bad_client_key", + "bad_client_cert_key", + "invalid_inclusion", + None, + ], +) +async def test_bad_certificate( + hass, + mqtt_mock_entry_no_yaml_config, + mock_try_connection_success, + tmp_path, + mock_ssl_context, + test_error, + mock_process_uploaded_file, +): + """Test bad certificate tests.""" + # Mock certificate files + file_id = mock_process_uploaded_file.file_id + test_input = { + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE], + mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], + mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], + "set_ca_cert": True, + "set_client_cert": True, + } + set_client_cert = True + set_ca_cert = "custom" + tls_insecure = False + if test_error == "bad_certificate": + # CA chain is not loading + mock_ssl_context["context"]().load_verify_locations.side_effect = SSLError + elif test_error == "bad_client_cert": + # Client certificate is invalid + mock_ssl_context["load_pem_x509_certificate"].side_effect = ValueError + elif test_error == "bad_client_key": + # Client key file is invalid + mock_ssl_context["load_pem_private_key"].side_effect = ValueError + elif test_error == "bad_client_cert_key": + # Client key file file and certificate do not pair + mock_ssl_context["context"]().load_cert_chain.side_effect = SSLError + elif test_error == "invalid_inclusion": + # Client key file without client cert, client cert without key file + test_input.pop(mqtt.CONF_CLIENT_KEY) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mock_try_connection.return_value = True + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Add at least one advanced option to get the full form + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + mqtt.CONF_CLIENT_ID: "custom1234", + mqtt.CONF_KEEPALIVE: 60, + mqtt.CONF_TLS_INSECURE: False, + mqtt.CONF_PROTOCOL: "3.1.1", + } + + mqtt_mock.async_connect.reset_mock() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "broker" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_KEEPALIVE: 60, + "set_client_cert": set_client_cert, + "set_ca_cert": set_ca_cert, + mqtt.CONF_TLS_INSECURE: tls_insecure, + mqtt.CONF_PROTOCOL: "3.1.1", + mqtt.CONF_CLIENT_ID: "custom1234", + }, + ) + test_input["set_client_cert"] = set_client_cert + test_input["set_ca_cert"] = set_ca_cert + test_input["tls_insecure"] = tls_insecure + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=test_input, + ) + if test_error is not None: + assert result["errors"]["base"] == test_error + return + assert result["errors"] == {} + + +@pytest.mark.parametrize( + "input_value, error", + [ + ("", True), + ("-10", True), + ("10", True), + ("15", False), + ("26", False), + ("100", False), + ], +) +async def test_keepalive_validation( + hass, + mqtt_mock_entry_no_yaml_config, + mock_try_connection, + mock_reload_after_entry_update, + input_value, + error, +): + """Test validation of the keep alive option.""" + + test_input = { + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_KEEPALIVE: input_value, + } + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mock_try_connection.return_value = True + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Add at least one advanced option to get the full form + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + mqtt.CONF_CLIENT_ID: "custom1234", + } + + mqtt_mock.async_connect.reset_mock() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "broker" + + if error: + with pytest.raises(vol.MultipleInvalid): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=test_input, + ) + return + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=test_input, + ) + assert not result["errors"] async def test_disable_birth_will( @@ -415,6 +698,7 @@ async def test_disable_birth_will( result["flow_id"], user_input={ mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", "birth_enable": False, "birth_topic": "ha_state/online", "birth_payload": "online", @@ -435,6 +719,7 @@ async def test_disable_birth_will( mqtt.CONF_USERNAME: "user", mqtt.CONF_PASSWORD: "pass", mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", mqtt.CONF_BIRTH_MESSAGE: {}, mqtt.CONF_WILL_MESSAGE: {}, } @@ -444,6 +729,64 @@ async def test_disable_birth_will( assert mock_reload_after_entry_update.call_count == 1 +async def test_invalid_discovery_prefix( + hass, + mqtt_mock_entry_no_yaml_config, + mock_try_connection, + mock_reload_after_entry_update, +): + """Test setting an invalid discovery prefix.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mock_try_connection.return_value = True + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", + } + + mqtt_mock.async_connect.reset_mock() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "broker" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "options" + + await hass.async_block_till_done() + assert mqtt_mock.async_connect.call_count == 0 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant#invalid", + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "options" + assert result["errors"]["base"] == "bad_discovery_prefix" + assert config_entry.data == { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", + } + + await hass.async_block_till_done() + # assert that the entry was not reloaded with the new config + assert mock_reload_after_entry_update.call_count == 0 + + def get_default(schema, key): """Get default value for key in voluptuous schema.""" for k in schema.keys(): @@ -614,6 +957,47 @@ async def test_option_flow_default_suggested_values( await hass.async_block_till_done() +@pytest.mark.parametrize( + "advanced_options, step_id", [(False, "options"), (True, "broker")] +) +async def test_skipping_advanced_options( + hass, + mqtt_mock_entry_no_yaml_config, + mock_try_connection, + mock_reload_after_entry_update, + advanced_options, + step_id, +): + """Test advanced options option.""" + + test_input = { + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + "advanced_options": advanced_options, + } + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + mock_try_connection.return_value = True + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Initiate with a basic setup + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + } + + mqtt_mock.async_connect.reset_mock() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "broker" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=test_input, + ) + assert result["step_id"] == step_id + + async def test_options_user_connection_fails(hass, mock_try_connection_time_out): """Test if connection cannot be made.""" config_entry = MockConfigEntry(domain=mqtt.DOMAIN) @@ -716,50 +1100,57 @@ async def test_options_bad_will_message_fails(hass, mock_try_connection): async def test_try_connection_with_advanced_parameters( - hass, mock_try_connection_success, tmp_path + hass, + mqtt_mock_entry_with_yaml_config, + mock_try_connection_success, + tmp_path, + mock_ssl_context, + mock_process_uploaded_file, ): """Test config flow with advanced parameters from config.""" - # Mock certificate files - certfile = tmp_path / "cert.pem" - certfile.write_text("## mock certificate file ##") - keyfile = tmp_path / "key.pem" - keyfile.write_text("## mock key file ##") + + with open(tmp_path / "client.crt", "wb") as certfile: + certfile.write(MOCK_CLIENT_CERT) + with open(tmp_path / "client.key", "wb") as keyfile: + keyfile.write(MOCK_CLIENT_KEY) + config = { "certificate": "auto", "tls_insecure": True, - "client_cert": certfile, - "client_key": keyfile, + "client_cert": str(tmp_path / "client.crt"), + "client_key": str(tmp_path / "client.key"), } new_yaml_config_file = tmp_path / "configuration.yaml" new_yaml_config = yaml.dump({mqtt.DOMAIN: config}) new_yaml_config_file.write_text(new_yaml_config) assert new_yaml_config_file.read_text() == new_yaml_config + config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry.add_to_hass(hass) + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "pass", + mqtt.CONF_KEEPALIVE: 30, + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "ha_state/online", + mqtt.ATTR_PAYLOAD: "online", + mqtt.ATTR_QOS: 1, + mqtt.ATTR_RETAIN: True, + }, + mqtt.CONF_WILL_MESSAGE: { + mqtt.ATTR_TOPIC: "ha_state/offline", + mqtt.ATTR_PAYLOAD: "offline", + mqtt.ATTR_QOS: 2, + mqtt.ATTR_RETAIN: False, + }, + } + with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) await hass.async_block_till_done() - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) - config_entry.add_to_hass(hass) - config_entry.data = { - mqtt.CONF_BROKER: "test-broker", - mqtt.CONF_PORT: 1234, - mqtt.CONF_USERNAME: "user", - mqtt.CONF_PASSWORD: "pass", - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_BIRTH_MESSAGE: { - mqtt.ATTR_TOPIC: "ha_state/online", - mqtt.ATTR_PAYLOAD: "online", - mqtt.ATTR_QOS: 1, - mqtt.ATTR_RETAIN: True, - }, - mqtt.CONF_WILL_MESSAGE: { - mqtt.ATTR_TOPIC: "ha_state/offline", - mqtt.ATTR_PAYLOAD: "offline", - mqtt.ATTR_QOS: 2, - mqtt.ATTR_RETAIN: False, - }, - } - # Test default/suggested values from config result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == data_entry_flow.FlowResultType.FORM @@ -767,16 +1158,32 @@ async def test_try_connection_with_advanced_parameters( defaults = { mqtt.CONF_BROKER: "test-broker", mqtt.CONF_PORT: 1234, + "set_client_cert": True, + "set_ca_cert": "auto", } suggested = { mqtt.CONF_USERNAME: "user", mqtt.CONF_PASSWORD: "pass", + mqtt.CONF_TLS_INSECURE: True, + mqtt.CONF_PROTOCOL: "3.1.1", } for k, v in defaults.items(): assert get_default(result["data_schema"].schema, k) == v for k, v in suggested.items(): assert get_suggested(result["data_schema"].schema, k) == v + # test the client cert and key were migrated to the entry + assert config_entry.data[mqtt.CONF_CLIENT_CERT] == MOCK_CLIENT_CERT.decode( + "utf-8" + ) + assert config_entry.data[mqtt.CONF_CLIENT_KEY] == MOCK_CLIENT_KEY.decode( + "utf-8" + ) + assert config_entry.data[mqtt.CONF_CERTIFICATE] == "auto" + + # test we can chante username and password + # as it was configured as auto in configuration.yaml is is migrated now + mock_try_connection_success.reset_mock() result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ @@ -784,24 +1191,135 @@ async def test_try_connection_with_advanced_parameters( mqtt.CONF_PORT: 2345, mqtt.CONF_USERNAME: "us3r", mqtt.CONF_PASSWORD: "p4ss", + "set_ca_cert": "auto", + "set_client_cert": True, + mqtt.CONF_TLS_INSECURE: True, }, ) assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} assert result["step_id"] == "options" + await hass.async_block_till_done() # check if the username and password was set from config flow and not from configuration.yaml assert mock_try_connection_success.username_pw_set.mock_calls[0][1] == ( "us3r", "p4ss", ) - # check if tls_insecure_set is called assert mock_try_connection_success.tls_insecure_set.mock_calls[0][1] == (True,) - # check if the certificate settings were set from configuration.yaml + # check if the ca certificate settings were not set during connection test assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ "certfile" - ] == str(certfile) + ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_CERT) assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ "keyfile" - ] == str(keyfile) + ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_KEY) + + # Accept default option + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + +async def test_setup_with_advanced_settings( + hass, mock_try_connection, tmp_path, mock_ssl_context, mock_process_uploaded_file +): + """Test config flow setup with advanced parameters.""" + file_id = mock_process_uploaded_file.file_id + + config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry.add_to_hass(hass) + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + } + + mock_try_connection.return_value = True + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == "form" + assert result["step_id"] == "broker" + assert result["data_schema"].schema["advanced_options"] + + # first iteration, basic settings + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "secret", + "advanced_options": True, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[mqtt.CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[mqtt.CONF_PROTOCOL] + assert mqtt.CONF_CLIENT_CERT not in result["data_schema"].schema + assert mqtt.CONF_CLIENT_KEY not in result["data_schema"].schema + + # second iteration, advanced settings with request for client cert + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "auto", + "set_client_cert": True, + mqtt.CONF_TLS_INSECURE: True, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[mqtt.CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[mqtt.CONF_PROTOCOL] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_CERT] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_KEY] + + # third iteration, advanced settings with client cert and key set + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "auto", + "set_client_cert": True, + mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], + mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], + mqtt.CONF_TLS_INSECURE: True, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "options" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", + }, + ) + assert result["type"] == "create_entry" diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 90df45b65a1c5f1bf7a87681abb0f6c5b10d5f69..426ccb5806fa63f168e2107526fcbffc04dc6d8c 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1940,6 +1940,7 @@ async def test_update_incomplete_entry( # Config entry data should now be updated assert entry.data == { "port": 1234, + "discovery_prefix": "homeassistant", "broker": "yaml_broker", } # Warnings about broker deprecated, but not about other keys with default values @@ -2969,7 +2970,7 @@ async def test_remove_unknown_conf_entry_options(hass, mqtt_client_mock, caplog) mqtt_config_entry_data = { mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}, - mqtt.client.CONF_PROTOCOL: mqtt.const.PROTOCOL_311, + "old_option": "old_value", } entry = MockConfigEntry( @@ -2985,8 +2986,7 @@ async def test_remove_unknown_conf_entry_options(hass, mqtt_client_mock, caplog) assert mqtt.client.CONF_PROTOCOL not in entry.data assert ( "The following unsupported configuration options were removed from the " - "MQTT config entry: {'protocol'}. Add them to configuration.yaml if they " - "are needed" + "MQTT config entry: {'old_option'}" ) in caplog.text diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 6cfaa9678bba3d3b894377a7269a82f31d54ef75..1884d04efc30738c20e909a8d66f7bdc3517ef52 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -313,6 +313,12 @@ async def test_setting_sensor_value_via_mqtt_json_message( assert state.state == "100" + # Make sure the state is written when a sensor value is reset to '' + async_fire_mqtt_message(hass, "test-topic", '{ "val": "" }') + state = hass.states.get("sensor.test") + + assert state.state == "" + async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_state( hass, mqtt_mock_entry_with_yaml_config diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py new file mode 100644 index 0000000000000000000000000000000000000000..e7d75ee7cc80735bcbecc7da28b5b1e86696f4ae --- /dev/null +++ b/tests/components/mqtt/test_update.py @@ -0,0 +1,525 @@ +"""The tests for mqtt update component.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt, update +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, + Platform, +) +from homeassistant.setup import async_setup_component + +from .test_common import ( + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setup_manual_entity_from_yaml, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message + +DEFAULT_CONFIG = { + mqtt.DOMAIN: { + update.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "latest_version_topic": "test-topic", + "command_topic": "test-topic", + "payload_install": "install", + } + } +} + + +@pytest.fixture(autouse=True) +def update_platform_only(): + """Only setup the update platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.UPDATE]): + yield + + +async def test_run_update_setup(hass, mqtt_mock_entry_with_yaml_config): + """Test that it fetches the given payload.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": installed_version_topic, + "latest_version_topic": latest_version_topic, + "name": "Test Update", + "release_summary": "Test release summary", + "release_url": "https://example.com/release", + "title": "Test Update Title", + "entity_picture": "https://example.com/icon.png", + } + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") + async_fire_mqtt_message(hass, latest_version_topic, "1.9.0") + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_OFF + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "1.9.0" + assert state.attributes.get("release_summary") == "Test release summary" + assert state.attributes.get("release_url") == "https://example.com/release" + assert state.attributes.get("title") == "Test Update Title" + assert state.attributes.get("entity_picture") == "https://example.com/icon.png" + + async_fire_mqtt_message(hass, latest_version_topic, "2.0.0") + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + + +async def test_value_template(hass, mqtt_mock_entry_with_yaml_config): + """Test that it fetches the given payload with a template.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": installed_version_topic, + "value_template": "{{ value_json.installed }}", + "latest_version_topic": latest_version_topic, + "latest_version_template": "{{ value_json.latest }}", + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, installed_version_topic, '{"installed":"1.9.0"}') + async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"1.9.0"}') + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_OFF + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "1.9.0" + assert ( + state.attributes.get("entity_picture") + == "https://brands.home-assistant.io/_/mqtt/icon.png" + ) + + async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"2.0.0"}') + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + + +async def test_empty_json_state_message(hass, mqtt_mock_entry_with_yaml_config): + """Test an empty JSON payload.""" + state_topic = "test/state-topic" + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": state_topic, + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, state_topic, "{}") + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_UNKNOWN + + +async def test_json_state_message(hass, mqtt_mock_entry_with_yaml_config): + """Test whether it fetches data from a JSON payload.""" + state_topic = "test/state-topic" + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": state_topic, + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message( + hass, + state_topic, + '{"installed_version":"1.9.0","latest_version":"1.9.0",' + '"title":"Test Update Title","release_url":"https://example.com/release",' + '"release_summary":"Test release summary"}', + ) + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_OFF + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "1.9.0" + assert state.attributes.get("release_summary") == "Test release summary" + assert state.attributes.get("release_url") == "https://example.com/release" + assert state.attributes.get("title") == "Test Update Title" + + async_fire_mqtt_message( + hass, + state_topic, + '{"installed_version":"1.9.0","latest_version":"2.0.0","title":"Test Update Title"}', + ) + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + + +async def test_json_state_message_with_template(hass, mqtt_mock_entry_with_yaml_config): + """Test whether it fetches data from a JSON payload with template.""" + state_topic = "test/state-topic" + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": state_topic, + "value_template": '{{ {"installed_version": value_json.installed, "latest_version": value_json.latest} | to_json }}', + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, state_topic, '{"installed":"1.9.0","latest":"1.9.0"}') + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_OFF + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "1.9.0" + + async_fire_mqtt_message(hass, state_topic, '{"installed":"1.9.0","latest":"2.0.0"}') + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + + +async def test_run_install_service(hass, mqtt_mock_entry_with_yaml_config): + """Test that install service works.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + command_topic = "test/install-command" + + await async_setup_component( + hass, + mqtt.DOMAIN, + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": installed_version_topic, + "latest_version_topic": latest_version_topic, + "command_topic": command_topic, + "payload_install": "install", + "name": "Test Update", + } + } + }, + ) + await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() + + async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") + async_fire_mqtt_message(hass, latest_version_topic, "2.0.0") + + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.state == STATE_ON + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_update"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with(command_topic, "install", 0, False) + + +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + update.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass, mqtt_mock_entry_with_yaml_config, caplog +): + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + update.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + update.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): + """Test unique id option only creates one update per unique_id.""" + config = { + mqtt.DOMAIN: { + update.DOMAIN: [ + { + "name": "Bear", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Milk", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, config + ) + + +async def test_discovery_removal_update(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test removal of discovered update.""" + data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][update.DOMAIN]) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, data + ) + + +async def test_discovery_update_update(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test update of discovered update.""" + config1 = { + "name": "Beer", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + } + config2 = { + "name": "Milk", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + } + + await help_test_discovery_update( + hass, mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_update( + hass, mqtt_mock_entry_no_yaml_config, caplog +): + """Test update of discovered update.""" + data1 = '{ "name": "Beer", "state_topic": "installed-topic", "latest_version_topic": "latest-topic"}' + with patch( + "homeassistant.components.mqtt.update.MqttUpdate.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + update.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = '{ "name": "Milk", "state_topic": "installed-topic", "latest_version_topic": "latest-topic" }' + + await help_test_discovery_broken( + hass, mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): + """Test MQTT update device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): + """Test MQTT update device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setup_manual_entity_from_yaml(hass): + """Test setup manual configured MQTT entity.""" + platform = update.DOMAIN + await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry(hass, mqtt_mock_entry_with_yaml_config, tmp_path): + """Test unloading the config entry.""" + domain = update.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + ) diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py new file mode 100644 index 0000000000000000000000000000000000000000..a8eaba0421f414737d22984d92247a7a7c77d305 --- /dev/null +++ b/tests/components/mqtt/test_util.py @@ -0,0 +1,49 @@ +"""Test MQTT utils.""" + +from random import getrandbits +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt + + +@pytest.fixture(autouse=True) +def mock_temp_dir(): + """Mock the certificate temp directory.""" + with patch( + # Patch temp dir name to avoid tests fail running in parallel + "homeassistant.components.mqtt.util.TEMP_DIR_NAME", + "home-assistant-mqtt" + f"-{getrandbits(10):03x}", + ) as mocked_temp_dir: + yield mocked_temp_dir + + +@pytest.mark.parametrize( + "option,content,file_created", + [ + (mqtt.CONF_CERTIFICATE, "auto", False), + (mqtt.CONF_CERTIFICATE, "### CA CERTIFICATE ###", True), + (mqtt.CONF_CLIENT_CERT, "### CLIENT CERTIFICATE ###", True), + (mqtt.CONF_CLIENT_KEY, "### PRIVATE KEY ###", True), + ], +) +async def test_async_create_certificate_temp_files( + hass, mock_temp_dir, option, content, file_created +): + """Test creating and reading certificate files.""" + config = {option: content} + await mqtt.util.async_create_certificate_temp_files(hass, config) + + file_path = mqtt.util.get_file_path(option) + assert bool(file_path) is file_created + assert ( + mqtt.util.migrate_certificate_file_to_content(file_path or content) == content + ) + + +async def test_reading_non_exitisting_certificate_file(): + """Test reading a non existing certificate file.""" + assert ( + mqtt.util.migrate_certificate_file_to_content("/home/file_not_exists") is None + ) diff --git a/tests/components/mysensors/test_sensor.py b/tests/components/mysensors/test_sensor.py index 58258682d5b9836276b3a26fb911a2731dd50998..3a1b7b568723cb7797b596db5f4840088c427528 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -21,7 +21,11 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from tests.common import MockConfigEntry @@ -124,7 +128,7 @@ async def test_distance_sensor( @pytest.mark.parametrize( "unit_system, unit", - [(METRIC_SYSTEM, TEMP_CELSIUS), (IMPERIAL_SYSTEM, TEMP_FAHRENHEIT)], + [(METRIC_SYSTEM, TEMP_CELSIUS), (US_CUSTOMARY_SYSTEM, TEMP_FAHRENHEIT)], ) async def test_temperature_sensor( hass: HomeAssistant, diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index b6f278d4e94846ee88bda92961cadadfc509e52b..a6d1130559920409af1eb6bcce4bca16dbaff054 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -32,12 +32,30 @@ async def test_config_not_ready(hass): unique_id="aa:bb:cc:dd:ee:ff", data={"host": "10.10.2.3"}, ) + entry.add_to_hass(hass) with patch( "homeassistant.components.nam.NettigoAirMonitor.initialize", side_effect=ApiError("API Error"), ): - entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_not_ready_while_checking_credentials(hass): + """Test for setup failure if the connection fails while checking credentials.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="10.10.2.3", + unique_id="aa:bb:cc:dd:ee:ff", + data={"host": "10.10.2.3"}, + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + side_effect=ApiError("API Error"), + ): await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -50,12 +68,12 @@ async def test_config_auth_failed(hass): unique_id="aa:bb:cc:dd:ee:ff", data={"host": "10.10.2.3"}, ) + entry.add_to_hass(hass) with patch( "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", side_effect=AuthFailed("Authorization has failed"), ): - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/nest/test_climate_sdm.py b/tests/components/nest/test_climate_sdm.py index b6992a5772fd63472b9d451a64cd6d1487a4abd7..ffe957a3e28ebbc3da80d1720fd67cd2d5217936 100644 --- a/tests/components/nest/test_climate_sdm.py +++ b/tests/components/nest/test_climate_sdm.py @@ -34,7 +34,11 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -1465,3 +1469,63 @@ async def test_thermostat_hvac_mode_failure( with pytest.raises(HomeAssistantError): await common.async_set_preset_mode(hass, PRESET_ECO) await hass.async_block_till_done() + + +async def test_thermostat_available( + hass: HomeAssistant, setup_platform: PlatformSetup, create_device: CreateDevice +): + """Test a thermostat that is available.""" + create_device.create( + { + "sdm.devices.traits.ThermostatHvac": { + "status": "COOLING", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "COOL", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 29.9, + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "coolCelsius": 28.0, + }, + "sdm.devices.traits.Connectivity": {"status": "ONLINE"}, + }, + ) + await setup_platform() + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVACMode.COOL + + +async def test_thermostat_unavailable( + hass: HomeAssistant, setup_platform: PlatformSetup, create_device: CreateDevice +): + """Test a thermostat that is unavailable.""" + create_device.create( + { + "sdm.devices.traits.ThermostatHvac": { + "status": "COOLING", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "COOL", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 29.9, + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "coolCelsius": 28.0, + }, + "sdm.devices.traits.Connectivity": {"status": "OFFLINE"}, + }, + ) + await setup_platform() + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == STATE_UNAVAILABLE diff --git a/tests/components/nest/test_sensor_sdm.py b/tests/components/nest/test_sensor_sdm.py index d1a89317959d69a03ba400f65c802b755eba3a23..c3698cf4123c6b3c2ea87694f742b44d365c3145 100644 --- a/tests/components/nest/test_sensor_sdm.py +++ b/tests/components/nest/test_sensor_sdm.py @@ -20,6 +20,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, + STATE_UNAVAILABLE, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -90,6 +91,58 @@ async def test_thermostat_device( assert device.identifiers == {("nest", DEVICE_ID)} +async def test_thermostat_device_available( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +): + """Test a thermostat with temperature and humidity sensors that is Online.""" + create_device.create( + { + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1, + }, + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 35.0, + }, + "sdm.devices.traits.Connectivity": {"status": "ONLINE"}, + } + ) + await setup_platform() + + temperature = hass.states.get("sensor.my_sensor_temperature") + assert temperature is not None + assert temperature.state == "25.1" + + humidity = hass.states.get("sensor.my_sensor_humidity") + assert humidity is not None + assert humidity.state == "35" + + +async def test_thermostat_device_unavailable( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +): + """Test a thermostat with temperature and humidity sensors that is Offline.""" + create_device.create( + { + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1, + }, + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 35.0, + }, + "sdm.devices.traits.Connectivity": {"status": "OFFLINE"}, + } + ) + await setup_platform() + + temperature = hass.states.get("sensor.my_sensor_temperature") + assert temperature is not None + assert temperature.state == STATE_UNAVAILABLE + + humidity = hass.states.get("sensor.my_sensor_humidity") + assert humidity is not None + assert humidity.state == STATE_UNAVAILABLE + + async def test_no_devices(hass: HomeAssistant, setup_platform: PlatformSetup): """Test no devices returned by the api.""" await setup_platform() diff --git a/tests/components/netatmo/fixtures/getstationsdata.json b/tests/components/netatmo/fixtures/getstationsdata.json index 822a4c11a5016a7ff96fba1c6c3a6a91eb4340e2..10c3ca85e06c2c96bbaf56255ae8e4aba5da7c64 100644 --- a/tests/components/netatmo/fixtures/getstationsdata.json +++ b/tests/components/netatmo/fixtures/getstationsdata.json @@ -114,7 +114,7 @@ "battery_percent": 79 }, { - "_id": "12:34:56:03:1b:e4", + "_id": "12:34:56:03:1b:e5", "type": "NAModule2", "module_name": "Garden", "data_type": ["Wind"], @@ -430,63 +430,203 @@ "modules": [] }, { - "_id": "12:34:56:58:c8:54", - "date_setup": 1605594014, - "last_setup": 1605594014, + "_id": "12:34:56:80:bb:26", + "station_name": "MYHOME (Palier)", + "date_setup": 1558709904, + "last_setup": 1558709904, "type": "NAMain", - "last_status_store": 1605878352, - "firmware": 178, - "wifi_status": 47, + "last_status_store": 1644582700, + "module_name": "Palier", + "firmware": 181, + "last_upgrade": 1558709906, + "wifi_status": 57, "reachable": true, "co2_calibrating": false, "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], "place": { - "altitude": 65, - "city": "Njurunda District", - "country": "SE", - "timezone": "Europe/Stockholm", - "location": [17.123456, 62.123456] + "altitude": 329, + "city": "Someplace", + "country": "FR", + "timezone": "Europe/Paris", + "location": [6.1234567, 46.123456] }, - "station_name": "Njurunda (Indoor)", - "home_id": "5fb36b9ec68fd10c6467ca65", - "home_name": "Njurunda", + "home_id": "91763b24c43d3e344f424e8b", + "home_name": "MYHOME", "dashboard_data": { - "time_utc": 1605878349, - "Temperature": 19.7, - "CO2": 993, - "Humidity": 40, - "Noise": 40, - "Pressure": 1015.6, - "AbsolutePressure": 1007.8, - "min_temp": 19.7, - "max_temp": 20.4, - "date_max_temp": 1605826917, - "date_min_temp": 1605873207, + "time_utc": 1644582694, + "Temperature": 21.1, + "CO2": 1339, + "Humidity": 45, + "Noise": 35, + "Pressure": 1026.8, + "AbsolutePressure": 974.5, + "min_temp": 21, + "max_temp": 21.8, + "date_max_temp": 1644534255, + "date_min_temp": 1644550420, "temp_trend": "stable", "pressure_trend": "up" }, "modules": [ { - "_id": "12:34:56:58:e6:38", + "_id": "12:34:56:80:1c:42", "type": "NAModule1", - "last_setup": 1605594034, + "module_name": "Outdoor", + "last_setup": 1558709954, "data_type": ["Temperature", "Humidity"], - "battery_percent": 100, + "battery_percent": 27, "reachable": true, "firmware": 50, - "last_message": 1605878347, - "last_seen": 1605878328, - "rf_status": 62, - "battery_vp": 6198, + "last_message": 1644582699, + "last_seen": 1644582699, + "rf_status": 68, + "battery_vp": 4678, "dashboard_data": { - "time_utc": 1605878328, - "Temperature": 0.6, - "Humidity": 77, - "min_temp": -2.1, - "max_temp": 1.5, - "date_max_temp": 1605865920, - "date_min_temp": 1605826904, - "temp_trend": "down" + "time_utc": 1644582648, + "Temperature": 9.4, + "Humidity": 57, + "min_temp": 6.7, + "max_temp": 9.8, + "date_max_temp": 1644534223, + "date_min_temp": 1644569369, + "temp_trend": "up" + } + }, + { + "_id": "12:34:56:80:c1:ea", + "type": "NAModule3", + "module_name": "Rain", + "last_setup": 1563734531, + "data_type": ["Rain"], + "battery_percent": 21, + "reachable": true, + "firmware": 12, + "last_message": 1644582699, + "last_seen": 1644582699, + "rf_status": 79, + "battery_vp": 4256, + "dashboard_data": { + "time_utc": 1644582686, + "Rain": 3.7, + "sum_rain_1": 0, + "sum_rain_24": 6.9 + } + }, + { + "_id": "12:34:56:80:44:92", + "type": "NAModule4", + "module_name": "Bedroom", + "last_setup": 1575915890, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 28, + "reachable": true, + "firmware": 51, + "last_message": 1644582699, + "last_seen": 1644582654, + "rf_status": 67, + "battery_vp": 4695, + "dashboard_data": { + "time_utc": 1644582654, + "Temperature": 19.3, + "CO2": 1076, + "Humidity": 53, + "min_temp": 19.2, + "max_temp": 19.7, + "date_max_temp": 1644534243, + "date_min_temp": 1644553418, + "temp_trend": "stable" + } + }, + { + "_id": "12:34:56:80:7e:18", + "type": "NAModule4", + "module_name": "Bathroom", + "last_setup": 1575915955, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 55, + "reachable": true, + "firmware": 51, + "last_message": 1644582699, + "last_seen": 1644582654, + "rf_status": 59, + "battery_vp": 5184, + "dashboard_data": { + "time_utc": 1644582654, + "Temperature": 19.4, + "CO2": 1930, + "Humidity": 55, + "min_temp": 19.4, + "max_temp": 21.8, + "date_max_temp": 1644534224, + "date_min_temp": 1644582039, + "temp_trend": "stable" + } + }, + { + "_id": "12:34:56:03:1b:e4", + "type": "NAModule2", + "module_name": "Garden", + "data_type": ["Wind"], + "last_setup": 1549193862, + "reachable": true, + "dashboard_data": { + "time_utc": 1559413170, + "WindStrength": 4, + "WindAngle": 217, + "GustStrength": 9, + "GustAngle": 206, + "max_wind_str": 21, + "max_wind_angle": 217, + "date_max_wind_str": 1559386669 + }, + "firmware": 19, + "last_message": 1559413177, + "last_seen": 1559413177, + "rf_status": 59, + "battery_vp": 5689, + "battery_percent": 85 + } + ] + }, + { + "_id": "00:11:22:2c:be:c8", + "station_name": "Zuhause (Kinderzimmer)", + "type": "NAMain", + "last_status_store": 1649146022, + "reachable": true, + "favorite": true, + "data_type": ["Pressure"], + "place": { + "altitude": 127, + "city": "Wiesbaden", + "country": "DE", + "timezone": "Europe/Berlin", + "location": [8.238054275512695, 50.07585525512695] + }, + "read_only": true, + "dashboard_data": { + "time_utc": 1649146022, + "Pressure": 1015.6, + "AbsolutePressure": 1000.4, + "pressure_trend": "stable" + }, + "modules": [ + { + "_id": "00:11:22:2c:ce:b6", + "type": "NAModule1", + "data_type": ["Temperature", "Humidity"], + "reachable": true, + "last_message": 1649146022, + "last_seen": 1649145996, + "dashboard_data": { + "time_utc": 1649145996, + "Temperature": 7.8, + "Humidity": 87, + "min_temp": 6.5, + "max_temp": 7.8, + "date_max_temp": 1649145996, + "date_min_temp": 1649118465, + "temp_trend": "up" } } ] diff --git a/tests/components/netatmo/fixtures/homesdata.json b/tests/components/netatmo/fixtures/homesdata.json index 93c04388f4c1901dc6bde3c26120d4d9ef63de25..6b24a7f8f9d4ac8a3433b2b9b8cc042cc6cc798d 100644 --- a/tests/components/netatmo/fixtures/homesdata.json +++ b/tests/components/netatmo/fixtures/homesdata.json @@ -23,7 +23,6 @@ "12:34:56:00:f1:62", "12:34:56:10:f1:66", "12:34:56:00:e3:9b", - "12:34:56:00:86:99", "0009999992" ] }, @@ -39,12 +38,6 @@ "type": "kitchen", "module_ids": ["12:34:56:03:a0:ac"] }, - { - "id": "2940411588", - "name": "Child", - "type": "custom", - "module_ids": ["12:34:56:26:cc:01"] - }, { "id": "222452125", "name": "Bureau", @@ -76,6 +69,12 @@ "name": "Corridor", "type": "corridor", "module_ids": ["10:20:30:bd:b8:1e"] + }, + { + "id": "100007520", + "name": "Toilettes", + "type": "toilets", + "module_ids": ["00:11:22:33:00:11:45:fe"] } ], "modules": [ @@ -120,15 +119,29 @@ "name": "Hall", "setup_date": 1544828430, "room_id": "3688132631", - "reachable": true, "modules_bridged": ["12:34:56:00:86:99", "12:34:56:00:e3:9b"] }, { - "id": "12:34:56:00:a5:a4", + "id": "12:34:56:10:f1:66", + "type": "NDB", + "name": "Netatmo-Doorbell", + "setup_date": 1602691361, + "room_id": "3688132631", + "reachable": true, + "hk_device_id": "123456007df1", + "customer_id": "1000010", + "network_lock": false, + "quick_display_zone": 62 + }, + { + "id": "12:34:56:10:b9:0e", "type": "NOC", - "name": "Garden", - "setup_date": 1544828430, - "reachable": true + "name": "Front", + "setup_date": 1509290599, + "reachable": true, + "customer_id": "A00010", + "network_lock": false, + "use_pincode": false }, { "id": "12:34:56:20:f5:44", @@ -155,33 +168,6 @@ "room_id": "222452125", "bridge": "12:34:56:20:f5:44" }, - { - "id": "12:34:56:10:f1:66", - "type": "NDB", - "name": "Netatmo-Doorbell", - "setup_date": 1602691361, - "room_id": "3688132631", - "reachable": true, - "hk_device_id": "123456007df1", - "customer_id": "1000010", - "network_lock": false, - "quick_display_zone": 62 - }, - { - "id": "12:34:56:00:e3:9b", - "type": "NIS", - "setup_date": 1620479901, - "bridge": "12:34:56:00:f1:62", - "name": "Sirene in hall" - }, - { - "id": "12:34:56:00:86:99", - "type": "NACamDoorTag", - "name": "Window Hall", - "setup_date": 1581177375, - "bridge": "12:34:56:00:f1:62", - "category": "window" - }, { "id": "12:34:56:30:d5:d4", "type": "NBG", @@ -199,16 +185,17 @@ "bridge": "12:34:56:30:d5:d4" }, { - "id": "12:34:56:37:11:ca", + "id": "12:34:56:80:bb:26", "type": "NAMain", - "name": "NetatmoIndoor", + "name": "Villa", "setup_date": 1419453350, + "room_id": "4122897288", "reachable": true, "modules_bridged": [ - "12:34:56:07:bb:3e", - "12:34:56:03:1b:e4", - "12:34:56:36:fc:de", - "12:34:56:05:51:20" + "12:34:56:80:44:92", + "12:34:56:80:7e:18", + "12:34:56:80:1c:42", + "12:34:56:80:c1:ea" ], "customer_id": "C00016", "hardware_version": 251, @@ -271,48 +258,46 @@ "module_offset": { "12:34:56:80:bb:26": { "a": 0.1 + }, + "03:00:00:03:1b:0e": { + "a": 0 } } }, { - "id": "12:34:56:36:fc:de", + "id": "12:34:56:80:1c:42", "type": "NAModule1", "name": "Outdoor", "setup_date": 1448565785, - "bridge": "12:34:56:37:11:ca" - }, - { - "id": "12:34:56:03:1b:e4", - "type": "NAModule2", - "name": "Garden", - "setup_date": 1543579864, - "bridge": "12:34:56:37:11:ca" + "bridge": "12:34:56:80:bb:26" }, { - "id": "12:34:56:05:51:20", + "id": "12:34:56:80:c1:ea", "type": "NAModule3", "name": "Rain", "setup_date": 1591770206, - "bridge": "12:34:56:37:11:ca" + "bridge": "12:34:56:80:bb:26" }, { - "id": "12:34:56:07:bb:3e", + "id": "12:34:56:80:44:92", "type": "NAModule4", "name": "Bedroom", "setup_date": 1484997703, - "bridge": "12:34:56:37:11:ca" + "bridge": "12:34:56:80:bb:26" }, { - "id": "12:34:56:26:68:92", - "type": "NHC", - "name": "Indoor", - "setup_date": 1571342643 + "id": "12:34:56:80:7e:18", + "type": "NAModule4", + "name": "Bathroom", + "setup_date": 1543579864, + "bridge": "12:34:56:80:bb:26" }, { - "id": "12:34:56:26:cc:01", - "type": "BNS", - "name": "Child", - "setup_date": 1571634243 + "id": "12:34:56:03:1b:e4", + "type": "NAModule2", + "name": "Garden", + "setup_date": 1543579864, + "bridge": "12:34:56:80:bb:26" }, { "id": "12:34:56:80:60:40", @@ -324,7 +309,8 @@ "12:34:56:80:00:12:ac:f2", "12:34:56:80:00:c3:69:3c", "12:34:56:00:00:a1:4c:da", - "12:34:56:00:01:01:01:a1" + "12:34:56:00:01:01:01:a1", + "00:11:22:33:00:11:45:fe" ] }, { @@ -342,6 +328,21 @@ "setup_date": 1641841262, "bridge": "12:34:56:80:60:40" }, + { + "id": "12:34:56:00:86:99", + "type": "NACamDoorTag", + "name": "Window Hall", + "setup_date": 1581177375, + "bridge": "12:34:56:00:f1:62", + "category": "window" + }, + { + "id": "12:34:56:00:e3:9b", + "type": "NIS", + "setup_date": 1620479901, + "bridge": "12:34:56:00:f1:62", + "name": "Sirene in hall" + }, { "id": "12:34:56:00:16:0e", "type": "NLE", @@ -440,6 +441,24 @@ "room_id": "100008999", "bridge": "12:34:56:80:60:40" }, + { + "id": "10:20:30:bd:b8:1e", + "type": "BNS", + "name": "Smarther", + "setup_date": 1638022197, + "room_id": "1002003001" + }, + { + "id": "00:11:22:33:00:11:45:fe", + "type": "NLF", + "on": false, + "brightness": 63, + "firmware_revision": 57, + "last_seen": 1657086939, + "power": 0, + "reachable": true, + "bridge": "12:34:56:80:60:40" + }, { "id": "12:34:56:00:01:01:01:a1", "type": "NLFN", @@ -761,80 +780,13 @@ "therm_mode": "schedule" }, { - "id": "111111111111111111111401", - "name": "Home with no modules", - "altitude": 9, - "coordinates": [1.23456789, 50.0987654], - "country": "BE", - "timezone": "Europe/Brussels", - "rooms": [ - { - "id": "1111111401", - "name": "Livingroom", - "type": "livingroom" - } - ], - "temperature_control_mode": "heating", - "therm_mode": "away", - "therm_setpoint_default_duration": 120, - "cooling_mode": "schedule", - "schedules": [ - { - "away_temp": 14, - "hg_temp": 7, - "name": "Week", - "timetable": [ - { - "zone_id": 1, - "m_offset": 0 - }, - { - "zone_id": 6, - "m_offset": 420 - } - ], - "zones": [ - { - "type": 0, - "name": "Comfort", - "rooms_temp": [], - "id": 0, - "rooms": [] - }, - { - "type": 1, - "name": "Nacht", - "rooms_temp": [], - "id": 1, - "rooms": [] - }, - { - "type": 5, - "name": "Eco", - "rooms_temp": [], - "id": 4, - "rooms": [] - }, - { - "type": 4, - "name": "Tussenin", - "rooms_temp": [], - "id": 5, - "rooms": [] - }, - { - "type": 4, - "name": "Ochtend", - "rooms_temp": [], - "id": 6, - "rooms": [] - } - ], - "id": "700000000000000000000401", - "selected": true, - "type": "therm" - } - ] + "id": "91763b24c43d3e344f424e8c", + "altitude": 112, + "coordinates": [52.516263, 13.377726], + "country": "DE", + "timezone": "Europe/Berlin", + "therm_setpoint_default_duration": 180, + "therm_mode": "schedule" } ], "user": { @@ -845,6 +797,8 @@ "unit_pressure": 0, "unit_system": 0, "unit_wind": 0, + "all_linked": false, + "type": "netatmo", "id": "91763b24c43d3e344f424e8b" } }, diff --git a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json index 4cd5dceec3bbb71c5ac1990a53497c075d6413ed..736d70be11cde3bae91bb025964e98f1e4e10294 100644 --- a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json +++ b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json @@ -14,25 +14,6 @@ "vpn_url": "https://prodvpn-eu-2.netatmo.net/restricted/10.255.123.45/609e27de5699fb18147ab47d06846631/MTRPn_BeWCav5RBq4U1OMDruTW4dkQ0NuMwNDAw11g,,", "is_local": true }, - { - "type": "NOC", - "firmware_revision": 3002000, - "monitoring": "on", - "sd_status": 4, - "connection": "wifi", - "homekit_status": "upgradable", - "floodlight": "auto", - "timelapse_available": true, - "id": "12:34:56:00:a5:a4", - "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.41/333333333333/444444444444,,", - "is_local": false, - "network_lock": false, - "firmware_name": "3.2.0", - "wifi_strength": 62, - "alim_status": 2, - "locked": false, - "wifi_state": "high" - }, { "id": "12:34:56:00:fa:d0", "type": "NAPlug", @@ -46,6 +27,7 @@ "type": "NATherm1", "firmware_revision": 65, "rf_strength": 58, + "battery_level": 3793, "boiler_valve_comfort_boost": false, "boiler_status": false, "anticipating": false, @@ -58,6 +40,7 @@ "type": "NRV", "firmware_revision": 79, "rf_strength": 51, + "battery_level": 3025, "bridge": "12:34:56:00:fa:d0", "battery_state": "full" }, @@ -67,18 +50,10 @@ "type": "NRV", "firmware_revision": 79, "rf_strength": 59, + "battery_level": 3029, "bridge": "12:34:56:00:fa:d0", "battery_state": "full" }, - { - "id": "12:34:56:26:cc:01", - "type": "BNS", - "firmware_revision": 32, - "wifi_strength": 50, - "boiler_valve_comfort_boost": false, - "boiler_status": true, - "cooler_status": false - }, { "type": "NDB", "last_ftp_event": { @@ -100,6 +75,25 @@ "wifi_strength": 66, "wifi_state": "medium" }, + { + "type": "NOC", + "firmware_revision": 3002000, + "monitoring": "on", + "sd_status": 4, + "connection": "wifi", + "homekit_status": "upgradable", + "floodlight": "auto", + "timelapse_available": true, + "id": "12:34:56:10:b9:0e", + "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.41/333333333333/444444444444,,", + "is_local": false, + "network_lock": false, + "firmware_name": "3.2.0", + "wifi_strength": 62, + "alim_status": 2, + "locked": false, + "wifi_state": "high" + }, { "boiler_control": "onoff", "dhw_control": "none", @@ -264,629 +258,43 @@ "bridge": "12:34:56:80:60:40" }, { - "id": "12:34:56:00:01:01:01:a1", - "brightness": 100, - "firmware_revision": 52, - "last_seen": 1604940167, - "on": false, - "power": 0, - "reachable": true, - "type": "NLFN", - "bridge": "12:34:56:80:60:40" - }, - { - "type": "NDB", - "last_ftp_event": { - "type": 3, - "time": 1631444443, - "id": 3 - }, - "id": "12:34:56:10:f1:66", - "websocket_connected": true, - "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,", - "is_local": false, - "alim_status": 2, - "connection": "wifi", - "firmware_name": "2.18.0", - "firmware_revision": 2018000, - "homekit_status": "configured", - "max_peers_reached": false, - "sd_status": 4, - "wifi_strength": 66, - "wifi_state": "medium" - }, - { - "boiler_control": "onoff", - "dhw_control": "none", - "firmware_revision": 22, - "hardware_version": 222, - "id": "12:34:56:20:f5:44", - "outdoor_temperature": 8.2, - "sequence_id": 19764, - "type": "OTH", - "wifi_strength": 57 - }, - { - "battery_level": 4176, - "boiler_status": false, + "id": "10:20:30:bd:b8:1e", + "type": "BNS", + "firmware_revision": 32, + "wifi_strength": 49, "boiler_valve_comfort_boost": false, - "firmware_revision": 6, - "id": "12:34:56:20:f5:8c", - "last_message": 1637684297, - "last_seen": 1637684297, - "radio_id": 2, - "reachable": true, - "rf_strength": 64, - "type": "OTM", - "bridge": "12:34:56:20:f5:44", - "battery_state": "full" - }, - { - "id": "12:34:56:30:d5:d4", - "type": "NBG", - "firmware_revision": 39, - "wifi_strength": 65, - "reachable": true - }, - { - "id": "0009999992", - "type": "NBR", - "current_position": 0, - "target_position": 0, - "target_position_step": 100, - "firmware_revision": 16, - "rf_strength": 0, - "last_seen": 1638353156, - "reachable": true, - "therm_measured_temperature": 5, - "heating_power_request": 1, - "therm_setpoint_temperature": 7, - "therm_setpoint_mode": "away", - "therm_setpoint_start_time": 0, - "therm_setpoint_end_time": 0, - "anticipating": false, - "open_window": false - }, - { - "id": "12:34:56:00:86:99", - "type": "NACamDoorTag", - "battery_state": "high", - "battery_level": 5240, - "firmware_revision": 58, - "rf_state": "full", - "rf_strength": 58, - "last_seen": 1642698124, - "last_activity": 1627757310, - "reachable": false, - "bridge": "12:34:56:00:f1:62", - "status": "no_news" - }, - { - "id": "12:34:56:00:e3:9b", - "type": "NIS", - "battery_state": "low", - "battery_level": 5438, - "firmware_revision": 209, - "rf_state": "medium", - "rf_strength": 62, - "last_seen": 1644569790, - "reachable": true, - "bridge": "12:34:56:00:f1:62", - "status": "no_sound", - "monitoring": "off" - }, - { - "id": "12:34:56:80:60:40", - "type": "NLG", - "offload": false, - "firmware_revision": 211, - "last_seen": 1644567372, - "wifi_strength": 51, - "reachable": true + "boiler_status": true, + "cooler_status": false }, { - "id": "12:34:56:80:00:12:ac:f2", - "type": "NLP", - "on": true, - "offload": false, - "firmware_revision": 62, - "last_seen": 1644569425, + "id": "00:11:22:33:00:11:45:fe", + "type": "NLF", + "on": false, + "brightness": 63, + "firmware_revision": 57, + "last_seen": 1657086939, "power": 0, "reachable": true, "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:80:00:c3:69:3c", - "type": "NLT", - "battery_state": "full", - "battery_level": 3300, - "firmware_revision": 42, - "last_seen": 0, - "reachable": false, - "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:00:16:0e", - "type": "NLE", - "firmware_revision": 14, - "wifi_strength": 38 - }, - { - "id": "12:34:56:00:16:0e#0", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#1", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#2", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#3", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#4", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#5", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#6", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#7", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#8", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, + } + ], + "rooms": [ { - "id": "12:34:56:00:00:a1:4c:da", - "type": "NLPC", - "firmware_revision": 62, - "last_seen": 1646511241, - "power": 476, + "id": "2746182631", "reachable": true, - "bridge": "12:34:56:80:60:40" + "therm_measured_temperature": 19.8, + "therm_setpoint_temperature": 12, + "therm_setpoint_mode": "away", + "therm_setpoint_start_time": 1559229567, + "therm_setpoint_end_time": 0 }, { - "id": "12:34:56:00:01:01:01:a1", - "brightness": 100, - "firmware_revision": 52, - "last_seen": 1604940167, - "on": false, - "power": 0, + "id": "2940411577", "reachable": true, - "type": "NLFN", - "bridge": "12:34:56:80:60:40" - }, - { - "type": "NDB", - "last_ftp_event": { - "type": 3, - "time": 1631444443, - "id": 3 - }, - "id": "12:34:56:10:f1:66", - "websocket_connected": true, - "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,", - "is_local": false, - "alim_status": 2, - "connection": "wifi", - "firmware_name": "2.18.0", - "firmware_revision": 2018000, - "homekit_status": "configured", - "max_peers_reached": false, - "sd_status": 4, - "wifi_strength": 66, - "wifi_state": "medium" - }, - { - "boiler_control": "onoff", - "dhw_control": "none", - "firmware_revision": 22, - "hardware_version": 222, - "id": "12:34:56:20:f5:44", - "outdoor_temperature": 8.2, - "sequence_id": 19764, - "type": "OTH", - "wifi_strength": 57 - }, - { - "battery_level": 4176, - "boiler_status": false, - "boiler_valve_comfort_boost": false, - "firmware_revision": 6, - "id": "12:34:56:20:f5:8c", - "last_message": 1637684297, - "last_seen": 1637684297, - "radio_id": 2, - "reachable": true, - "rf_strength": 64, - "type": "OTM", - "bridge": "12:34:56:20:f5:44", - "battery_state": "full" - }, - { - "id": "12:34:56:30:d5:d4", - "type": "NBG", - "firmware_revision": 39, - "wifi_strength": 65, - "reachable": true - }, - { - "id": "0009999992", - "type": "NBR", - "current_position": 0, - "target_position": 0, - "target_position_step": 100, - "firmware_revision": 16, - "rf_strength": 0, - "last_seen": 1638353156, - "reachable": true, - "therm_measured_temperature": 5, - "heating_power_request": 1, - "therm_setpoint_temperature": 7, - "therm_setpoint_mode": "away", - "therm_setpoint_start_time": 0, - "therm_setpoint_end_time": 0, - "anticipating": false, - "open_window": false - }, - { - "id": "12:34:56:00:86:99", - "type": "NACamDoorTag", - "battery_state": "high", - "battery_level": 5240, - "firmware_revision": 58, - "rf_state": "full", - "rf_strength": 58, - "last_seen": 1642698124, - "last_activity": 1627757310, - "reachable": false, - "bridge": "12:34:56:00:f1:62", - "status": "no_news" - }, - { - "id": "12:34:56:00:e3:9b", - "type": "NIS", - "battery_state": "low", - "battery_level": 5438, - "firmware_revision": 209, - "rf_state": "medium", - "rf_strength": 62, - "last_seen": 1644569790, - "reachable": true, - "bridge": "12:34:56:00:f1:62", - "status": "no_sound", - "monitoring": "off" - }, - { - "id": "12:34:56:80:60:40", - "type": "NLG", - "offload": false, - "firmware_revision": 211, - "last_seen": 1644567372, - "wifi_strength": 51, - "reachable": true - }, - { - "id": "12:34:56:80:00:12:ac:f2", - "type": "NLP", - "on": true, - "offload": false, - "firmware_revision": 62, - "last_seen": 1644569425, - "power": 0, - "reachable": true, - "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:80:00:c3:69:3c", - "type": "NLT", - "battery_state": "full", - "battery_level": 3300, - "firmware_revision": 42, - "last_seen": 0, - "reachable": false, - "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:00:16:0e", - "type": "NLE", - "firmware_revision": 14, - "wifi_strength": 38 - }, - { - "id": "12:34:56:00:16:0e#0", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#1", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#2", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#3", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#4", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#5", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#6", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#7", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#8", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:00:a1:4c:da", - "type": "NLPC", - "firmware_revision": 62, - "last_seen": 1646511241, - "power": 476, - "reachable": true, - "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:00:01:01:01:a1", - "brightness": 100, - "firmware_revision": 52, - "last_seen": 1604940167, - "on": false, - "power": 0, - "reachable": true, - "type": "NLFN", - "bridge": "12:34:56:80:60:40" - }, - { - "type": "NDB", - "last_ftp_event": { - "type": 3, - "time": 1631444443, - "id": 3 - }, - "id": "12:34:56:10:f1:66", - "websocket_connected": true, - "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,", - "is_local": false, - "alim_status": 2, - "connection": "wifi", - "firmware_name": "2.18.0", - "firmware_revision": 2018000, - "homekit_status": "configured", - "max_peers_reached": false, - "sd_status": 4, - "wifi_strength": 66, - "wifi_state": "medium" - }, - { - "boiler_control": "onoff", - "dhw_control": "none", - "firmware_revision": 22, - "hardware_version": 222, - "id": "12:34:56:20:f5:44", - "outdoor_temperature": 8.2, - "sequence_id": 19764, - "type": "OTH", - "wifi_strength": 57 - }, - { - "battery_level": 4176, - "boiler_status": false, - "boiler_valve_comfort_boost": false, - "firmware_revision": 6, - "id": "12:34:56:20:f5:8c", - "last_message": 1637684297, - "last_seen": 1637684297, - "radio_id": 2, - "reachable": true, - "rf_strength": 64, - "type": "OTM", - "bridge": "12:34:56:20:f5:44", - "battery_state": "full" - }, - { - "id": "12:34:56:30:d5:d4", - "type": "NBG", - "firmware_revision": 39, - "wifi_strength": 65, - "reachable": true - }, - { - "id": "0009999992", - "type": "NBR", - "current_position": 0, - "target_position": 0, - "target_position_step": 100, - "firmware_revision": 16, - "rf_strength": 0, - "last_seen": 1638353156, - "reachable": true, - "therm_measured_temperature": 5, - "heating_power_request": 1, - "therm_setpoint_temperature": 7, - "therm_setpoint_mode": "away", - "therm_setpoint_start_time": 0, - "therm_setpoint_end_time": 0, - "anticipating": false, - "open_window": false - }, - { - "id": "12:34:56:00:86:99", - "type": "NACamDoorTag", - "battery_state": "high", - "battery_level": 5240, - "firmware_revision": 58, - "rf_state": "full", - "rf_strength": 58, - "last_seen": 1642698124, - "last_activity": 1627757310, - "reachable": false, - "bridge": "12:34:56:00:f1:62", - "status": "no_news" - }, - { - "id": "12:34:56:00:e3:9b", - "type": "NIS", - "battery_state": "low", - "battery_level": 5438, - "firmware_revision": 209, - "rf_state": "medium", - "rf_strength": 62, - "last_seen": 1644569790, - "reachable": true, - "bridge": "12:34:56:00:f1:62", - "status": "no_sound", - "monitoring": "off" - }, - { - "id": "12:34:56:80:60:40", - "type": "NLG", - "offload": false, - "firmware_revision": 211, - "last_seen": 1644567372, - "wifi_strength": 51, - "reachable": true - }, - { - "id": "12:34:56:80:00:12:ac:f2", - "type": "NLP", - "on": true, - "offload": false, - "firmware_revision": 62, - "last_seen": 1644569425, - "power": 0, - "reachable": true, - "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:80:00:c3:69:3c", - "type": "NLT", - "battery_state": "full", - "battery_level": 3300, - "firmware_revision": 42, - "last_seen": 0, - "reachable": false, - "bridge": "12:34:56:80:60:40" - }, - { - "id": "12:34:56:00:16:0e", - "type": "NLE", - "firmware_revision": 14, - "wifi_strength": 38 - }, - { - "id": "12:34:56:00:16:0e#0", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#1", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#2", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#3", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#4", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#5", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#6", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#7", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:16:0e#8", - "type": "NLE", - "bridge": "12:34:56:00:16:0e" - }, - { - "id": "12:34:56:00:00:a1:4c:da", - "type": "NLPC", - "firmware_revision": 62, - "last_seen": 1646511241, - "power": 476, - "reachable": true, - "bridge": "12:34:56:80:60:40" - } - ], - "rooms": [ - { - "id": "2746182631", - "reachable": true, - "therm_measured_temperature": 19.8, - "therm_setpoint_temperature": 12, - "therm_setpoint_mode": "schedule", - "therm_setpoint_start_time": 1559229567, - "therm_setpoint_end_time": 0 - }, - { - "id": "2940411577", - "reachable": true, - "therm_measured_temperature": 5, - "heating_power_request": 1, + "therm_measured_temperature": 27, + "heating_power_request": 0, "therm_setpoint_temperature": 7, - "therm_setpoint_mode": "away", + "therm_setpoint_mode": "hg", "therm_setpoint_start_time": 0, "therm_setpoint_end_time": 0, "anticipating": false, @@ -905,15 +313,15 @@ "open_window": false }, { - "id": "2940411588", + "id": "1002003001", "reachable": true, "anticipating": false, "heating_power_request": 0, "open_window": false, - "humidity": 68, - "therm_measured_temperature": 19.9, - "therm_setpoint_temperature": 21.5, - "therm_setpoint_start_time": 1647793285, + "humidity": 67, + "therm_measured_temperature": 22, + "therm_setpoint_temperature": 22, + "therm_setpoint_start_time": 1647462737, "therm_setpoint_end_time": null, "therm_setpoint_mode": "home" } diff --git a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8c.json b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8c.json index d950c82a6a5ced28d2ea776ab4c191ff335d9608..406e24bc1077dc715b78c17e49b5bbbab2ec7166 100644 --- a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8c.json +++ b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8c.json @@ -1,12 +1,20 @@ { "status": "ok", - "time_server": 1559292041, + "time_server": 1642952130, "body": { "home": { - "modules": [], - "rooms": [], - "id": "91763b24c43d3e344f424e8c", - "persons": [] + "persons": [ + { + "id": "abcdef12-1111-0000-0000-000111222333", + "last_seen": 1489050910, + "out_of_sight": true + }, + { + "id": "abcdef12-2222-0000-0000-000111222333", + "last_seen": 1489078776, + "out_of_sight": true + } + ] } } } diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 027b0907d50aa429158fc9ef02a96ee866df4c0f..76397988187b492342eafa5ab84c7141cd2f11c2 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -33,7 +33,7 @@ async def test_setup_component_with_webhook(hass, config_entry, netatmo_auth): await hass.async_block_till_done() camera_entity_indoor = "camera.hall" - camera_entity_outdoor = "camera.garden" + camera_entity_outdoor = "camera.front" assert hass.states.get(camera_entity_indoor).state == "streaming" response = { "event_type": "off", @@ -59,8 +59,8 @@ async def test_setup_component_with_webhook(hass, config_entry, netatmo_auth): response = { "event_type": "light_mode", - "device_id": "12:34:56:00:a5:a4", - "camera_id": "12:34:56:00:a5:a4", + "device_id": "12:34:56:10:b9:0e", + "camera_id": "12:34:56:10:b9:0e", "event_id": "601dce1560abca1ebad9b723", "push_type": "NOC-light_mode", "sub_type": "on", @@ -72,8 +72,8 @@ async def test_setup_component_with_webhook(hass, config_entry, netatmo_auth): response = { "event_type": "light_mode", - "device_id": "12:34:56:00:a5:a4", - "camera_id": "12:34:56:00:a5:a4", + "device_id": "12:34:56:10:b9:0e", + "camera_id": "12:34:56:10:b9:0e", "event_id": "601dce1560abca1ebad9b723", "push_type": "NOC-light_mode", "sub_type": "auto", @@ -84,7 +84,7 @@ async def test_setup_component_with_webhook(hass, config_entry, netatmo_auth): response = { "event_type": "light_mode", - "device_id": "12:34:56:00:a5:a4", + "device_id": "12:34:56:10:b9:0e", "event_id": "601dce1560abca1ebad9b723", "push_type": "NOC-light_mode", } @@ -166,7 +166,7 @@ async def test_camera_image_vpn(hass, config_entry, requests_mock, netatmo_auth) uri = "https://prodvpn-eu-6.netatmo.net/10.20.30.41/333333333333/444444444444,," stream_uri = uri + "/live/files/high/index.m3u8" - camera_entity_indoor = "camera.garden" + camera_entity_indoor = "camera.front" cam = hass.states.get(camera_entity_indoor) assert cam is not None @@ -304,14 +304,14 @@ async def test_service_set_camera_light(hass, config_entry, netatmo_auth): await hass.async_block_till_done() data = { - "entity_id": "camera.garden", + "entity_id": "camera.front", "camera_light_mode": "on", } expected_data = { "modules": [ { - "id": "12:34:56:00:a5:a4", + "id": "12:34:56:10:b9:0e", "floodlight": "on", }, ], @@ -353,7 +353,6 @@ async def test_service_set_camera_light_invalid_type(hass, config_entry, netatmo assert excinfo.value.args == ("NACamera <Hall> does not have a floodlight",) -@pytest.mark.skip async def test_camera_reconnect_webhook(hass, config_entry): """Test webhook event on camera reconnect.""" fake_post_hits = 0 @@ -406,7 +405,7 @@ async def test_camera_reconnect_webhook(hass, config_entry): dt.utcnow() + timedelta(seconds=60), ) await hass.async_block_till_done() - assert fake_post_hits > calls + assert fake_post_hits >= calls async def test_webhook_person_event(hass, config_entry, netatmo_auth): @@ -472,7 +471,7 @@ async def test_setup_component_no_devices(hass, config_entry): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert fake_post_hits == 9 + assert fake_post_hits == 11 async def test_camera_image_raises_exception(hass, config_entry, requests_mock): diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index d37bab929e1518b1c3b9e64ec567f5594e653600..afe85049f95d8dc89dd870e68b779dbfc7c9d6c2 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -36,8 +36,7 @@ async def test_webhook_event_handling_thermostats(hass, config_entry, netatmo_au assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( - hass.states.get(climate_entity_livingroom).attributes["preset_mode"] - == "Schedule" + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" ) assert hass.states.get(climate_entity_livingroom).attributes["temperature"] == 12 @@ -80,8 +79,7 @@ async def test_webhook_event_handling_thermostats(hass, config_entry, netatmo_au assert hass.states.get(climate_entity_livingroom).state == "heat" assert ( - hass.states.get(climate_entity_livingroom).attributes["preset_mode"] - == "Schedule" + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" ) assert hass.states.get(climate_entity_livingroom).attributes["temperature"] == 21 @@ -194,8 +192,7 @@ async def test_webhook_event_handling_thermostats(hass, config_entry, netatmo_au assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( - hass.states.get(climate_entity_livingroom).attributes["preset_mode"] - == "Schedule" + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" ) @@ -213,8 +210,7 @@ async def test_service_preset_mode_frost_guard_thermostat( assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( - hass.states.get(climate_entity_livingroom).attributes["preset_mode"] - == "Schedule" + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" ) # Test service setting the preset mode to "frost guard" @@ -269,8 +265,7 @@ async def test_service_preset_mode_frost_guard_thermostat( assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( - hass.states.get(climate_entity_livingroom).attributes["preset_mode"] - == "Schedule" + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" ) @@ -286,8 +281,7 @@ async def test_service_preset_modes_thermostat(hass, config_entry, netatmo_auth) assert hass.states.get(climate_entity_livingroom).state == "auto" assert ( - hass.states.get(climate_entity_livingroom).attributes["preset_mode"] - == "Schedule" + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" ) # Test service setting the preset mode to "away" diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 187a89afeb65b20488601f46ba5120749f884295..65cc991ec674455369fdaa35cb4642d5bfde1f42 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -110,7 +110,7 @@ async def test_setup_component_with_config(hass, config_entry): await hass.async_block_till_done() - assert fake_post_hits == 8 + assert fake_post_hits == 10 mock_impl.assert_called_once() mock_webhook.assert_called_once() diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index b1a5270745ce87c5999ff2eaa63ae2ef44463684..526fb2fe518b4845481f160b60a213acd214b66c 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -27,14 +27,14 @@ async def test_camera_light_setup_and_services(hass, config_entry, netatmo_auth) await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK_ACTIVATION) await hass.async_block_till_done() - light_entity = "light.garden" + light_entity = "light.front" assert hass.states.get(light_entity).state == "unavailable" # Trigger light mode change response = { "event_type": "light_mode", - "device_id": "12:34:56:00:a5:a4", - "camera_id": "12:34:56:00:a5:a4", + "device_id": "12:34:56:10:b9:0e", + "camera_id": "12:34:56:10:b9:0e", "event_id": "601dce1560abca1ebad9b723", "push_type": "NOC-light_mode", "sub_type": "on", @@ -46,7 +46,7 @@ async def test_camera_light_setup_and_services(hass, config_entry, netatmo_auth) # Trigger light mode change with erroneous webhook data response = { "event_type": "light_mode", - "device_id": "12:34:56:00:a5:a4", + "device_id": "12:34:56:10:b9:0e", } await simulate_webhook(hass, webhook_id, response) @@ -62,7 +62,7 @@ async def test_camera_light_setup_and_services(hass, config_entry, netatmo_auth) ) await hass.async_block_till_done() mock_set_state.assert_called_once_with( - {"modules": [{"id": "12:34:56:00:a5:a4", "floodlight": "auto"}]} + {"modules": [{"id": "12:34:56:10:b9:0e", "floodlight": "auto"}]} ) # Test turning light on @@ -75,7 +75,7 @@ async def test_camera_light_setup_and_services(hass, config_entry, netatmo_auth) ) await hass.async_block_till_done() mock_set_state.assert_called_once_with( - {"modules": [{"id": "12:34:56:00:a5:a4", "floodlight": "on"}]} + {"modules": [{"id": "12:34:56:10:b9:0e", "floodlight": "on"}]} ) diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index d3ea8fb8167a659c6ce30c12d97ba10f3add52a6..9ef5637231615c385f4f2ee3412fef48bf6755fb 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -16,12 +16,12 @@ async def test_weather_sensor(hass, config_entry, netatmo_auth): await hass.async_block_till_done() - prefix = "sensor.netatmoindoor_" + prefix = "sensor.parents_bedroom_" - assert hass.states.get(f"{prefix}temperature").state == "24.6" - assert hass.states.get(f"{prefix}humidity").state == "36" - assert hass.states.get(f"{prefix}co2").state == "749" - assert hass.states.get(f"{prefix}pressure").state == "1017.3" + assert hass.states.get(f"{prefix}temperature").state == "20.3" + assert hass.states.get(f"{prefix}humidity").state == "63" + assert hass.states.get(f"{prefix}co2").state == "494" + assert hass.states.get(f"{prefix}pressure").state == "1014.5" async def test_public_weather_sensor(hass, config_entry, netatmo_auth): @@ -104,25 +104,25 @@ async def test_process_health(health, expected): @pytest.mark.parametrize( "uid, name, expected", [ - ("12:34:56:37:11:ca-reachable", "mystation_reachable", "True"), - ("12:34:56:03:1b:e4-rf_status", "mystation_yard_radio", "Full"), + ("12:34:56:03:1b:e4-reachable", "villa_garden_reachable", "True"), + ("12:34:56:03:1b:e4-rf_status", "villa_garden_radio", "Full"), ( - "12:34:56:37:11:ca-wifi_status", - "mystation_wifi_strength", - "Full", + "12:34:56:80:bb:26-wifi_status", + "villa_wifi_strength", + "High", ), ( - "12:34:56:37:11:ca-temp_trend", - "mystation_temperature_trend", + "12:34:56:80:bb:26-temp_trend", + "villa_temperature_trend", "stable", ), ( - "12:34:56:37:11:ca-pressure_trend", - "netatmo_mystation_pressure_trend", - "down", + "12:34:56:80:bb:26-pressure_trend", + "villa_pressure_trend", + "up", ), - ("12:34:56:05:51:20-sum_rain_1", "netatmo_mystation_yard_rain_last_hour", "0"), - ("12:34:56:05:51:20-sum_rain_24", "netatmo_mystation_yard_rain_today", "0"), + ("12:34:56:80:c1:ea-sum_rain_1", "villa_rain_rain_last_hour", "0"), + ("12:34:56:80:c1:ea-sum_rain_24", "villa_rain_rain_today", "6.9"), ("12:34:56:03:1b:e4-windangle", "netatmoindoor_garden_direction", "SW"), ( "12:34:56:03:1b:e4-windangle_value", diff --git a/tests/components/nibe_heatpump/test_config_flow.py b/tests/components/nibe_heatpump/test_config_flow.py index 2647102ba5ae1901ff91739f2f5de50f7d70cece..f7dc08c41bbf4133900e9bc7a063c368459d23af 100644 --- a/tests/components/nibe_heatpump/test_config_flow.py +++ b/tests/components/nibe_heatpump/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Nibe Heat Pump config flow.""" import errno +from socket import gaierror from unittest.mock import Mock, patch from nibe.coil import Coil @@ -150,13 +151,13 @@ async def test_unexpected_exception(hass: HomeAssistant, mock_connection: Mock) assert result2["errors"] == {"base": "unknown"} -async def test_invalid_ip(hass: HomeAssistant, mock_connection: Mock) -> None: +async def test_invalid_host(hass: HomeAssistant, mock_connection: Mock) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_connection.return_value.read_coil.side_effect = Exception() + mock_connection.return_value.read_coil.side_effect = gaierror() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {**MOCK_FLOW_USERDATA, "ip_address": "abcd"} diff --git a/tests/components/nina/fixtures/sample_warning_details.json b/tests/components/nina/fixtures/sample_warning_details.json index f9da183c553b68fc7102741ea5113dbcf334507e..aa176b2199ec0c82341a48e363afebe91d644651 100644 --- a/tests/components/nina/fixtures/sample_warning_details.json +++ b/tests/components/nina/fixtures/sample_warning_details.json @@ -157,5 +157,52 @@ ] } ] + }, + "biw.BIWAPP-69634": { + "identifier": "biw.BIWAPP-69634", + "sender": "CAP@biwapp.de", + "sent": "1999-08-07T10:59:00+02:00", + "status": "Actual", + "msgType": "Alert", + "scope": "Public", + "code": ["DVN:2", "BIWAPP"], + "info": [ + { + "language": "DE", + "category": ["Other"], + "event": "4", + "urgency": "Unknown", + "severity": "Minor", + "certainty": "Unknown", + "expires": "2002-08-07T10:59:00+02:00", + "headline": "Geflügelpest im Landkreis Cuxhaven - Teile des Landkreises Osterholz zur Überwachungszone erklärt", + "description": "In Beverstedt im Landkreis Cuxhaven ist am 20. Juli 2022 in einer Geflügelhaltung der Ausbruch der Geflügelpest (Vogelgrippe, Aviäre Influenza) amtlich festgestellt worden. Durch die geografische Nähe des Ausbruchsbetriebes zum Gebiet des Landkreises Osterholz musste das Veterinäramt des Landkreises zum Schutz vor einer Ausbreitung der Geflügelpest auch für sein Gebiet ein Restriktionsgebiet festlegen. Rund um den Ausbruchsort wurde eine Überwachungszone ausgewiesen. Eine entsprechende Tierseuchenbehördliche Allgemeinverfügung wurde vom Landkreis Osterholz erlassen und tritt am 23.07.2022 in Kraft.<br> <br>Die Überwachungszone mit einem Radius von mindestens zehn Kilometern um den Ausbruchsbetrieb erstreckt sich im Landkreis Osterholz innerhalb der Samtgemeinde Hambergen auf die Mitgliedsgemeinden Axstedt, Holste und Lübberstedt. Die vorgenannten Gemeinden sind vollständig zur Überwachungszone erklärt worden. Der genaue Grenzverlauf des Gebietes kann auch der interaktiven Karte im Internet entnommen werden.<br> <br>In der Überwachungszone liegen im Landkreis Osterholz rund 70 Geflügelhaltungen mit einem Gesamtbestand von rund 1.800 Tieren. Sie alle unterliegen mit der Allgemeinverfügung der sogenannten amtlichen Beobachtung. Für die Betriebe sind die Biosicherheitsmaßnahmen einzuhalten. Dazu zählen insbesondere Hygienemaßnahmen im laufenden Betrieb und eine ordnungsgemäße Schadnagerbekämpfung.<br> <br>Das Verbringen von Vögeln, Fleisch von Geflügel, Eiern und sonstige Nebenprodukte von Geflügel in und aus Betrieben in der Überwachungszone ist verboten. Auch Geflügeltransporte sind in der Überwachungszone verboten. Jeder Verdacht der Erkrankung auf Geflügelpest ist zudem dem Veterinäramt des Landkreises Osterholz unter der E-Mail-Adresse veterinaeramt@landkreis-osterholz.de sofort zu melden. Alle Hinweise, die innerhalb der Überwachungszone zu beachten sind, sind unter www.landkreis-osterholz.de/gefluegelpest zusammengefasst dargestellt.<br> <br>Die Veterinärbehörde weist zudem darauf hin, dass sämtliche Geflügelhaltungen – Hühner, Enten, Gänse, Fasane, Perlhühner, Rebhühner, Truthühner, Wachteln oder Laufvögel – der zuständigen Behörde angezeigt werden müssen. Wer dies bisher noch nicht gemacht hat und über keine Registriernummer für seinen Geflügelbestand verfügt, sollte die Meldung über das Veterinäramt umgehend nachholen.<br> <br>Das Beobachtungsgebiet kann frühestens 30 Tage nach der Grobreinigung des Ausbruchsbetriebes wieder aufgehoben werden. Hierüber wird der Landkreis Osterholz informieren.<br> <br>Die Allgemeinverfügung, eine Übersicht zur Überwachungszone und weitere Hinweise sind auf der Internetseite unter www.landkreis-osterholz.de/gefluegelpest zu finden.", + "parameter": [ + { + "valueName": "sender_langname", + "value": "Landkreis Osterholz" + }, + { + "valueName": "PHGEM", + "value": "740+10,770,792,817,100001" + }, + { + "valueName": "GRID", + "value": "101346,101954+7,102566+9,103177+13,103774,103790+13,104387+1,104403+13,105000+1,105016+15,105612+2,105630+15,106225+2,106241+18,106838+2,106853+18,107451+1,107464+22,108064+9,108075+23,108677+34,109290+34,109903+35,110516+35,111129+35,111742+35,112355,112357+34,112971+33,113587+30,114200+30,114814,114818+26,115432,115436+22,116050+21,116669+15,117283+5,117290+7,117897+3,117904+6,500001" + } + ], + "area": [ + { + "areaDesc": "Axstedt, Gnarrenburg, Grasberg, Hagen im Bremischen, Hambergen, Hepstedt, Holste, Lilienthal, Lübberstedt, Osterholz-Scharmbeck, Ritterhude, Schwanewede, Vollersode, Worpswede", + "geocode": [ + { + "valueName": "AreaId", + "value": "0" + } + ] + } + ] + } + ] } } diff --git a/tests/components/nina/fixtures/sample_warnings.json b/tests/components/nina/fixtures/sample_warnings.json index 0a41611b7ee5c80a7bd73844a0bec2b625f03a28..12d78b03ccec2b4870d80c49fb4e639a8a2c3e12 100644 --- a/tests/components/nina/fixtures/sample_warnings.json +++ b/tests/components/nina/fixtures/sample_warnings.json @@ -40,5 +40,29 @@ "onset": "2021-11-01T05:20:00+01:00", "sent": "2021-10-11T05:20:00+01:00", "expires": "3021-11-22T05:19:00+01:00" + }, + { + "id": "biw.BIWAPP-69634", + "payload": { + "version": 2, + "type": "ALERT", + "id": "biw.BIWAPP-69634", + "hash": "fdbafb6b164f549ff60b9adfa5b1c707069cdd178bf55f025066f319451660ad", + "data": { + "headline": "Geflügelpest im Landkreis Cuxhaven - Teile des Landkreises Osterholz zur Überwachungszone erklärt", + "provider": "BIWAPP", + "severity": "Minor", + "msgType": "Alert", + "area": { + "type": "GRID", + "data": "101346,101954+7,102566+9,103177+13,103774,103790+13,104387+1,104403+13,105000+1,105016+15,105612+2,105630+15,106225+2,106241+18,106838+2,106853+18,107451+1,107464+22,108064+9,108075+23,108677+34,109290+34,109903+35,110516+35,111129+35,111742+35,112355,112357+34,112971+33,113587+30,114200+30,114814,114818+26,115432,115436+22,116050+21,116669+15,117283+5,117290+7,117897+3,117904+6,500001" + } + } + }, + "i18nTitle": { + "de": "Geflügelpest im Landkreis Cuxhaven - Teile des Landkreises Osterholz zur Überwachungszone erklärt" + }, + "sent": "1999-08-07T10:59:00+02:00", + "expires": "2002-08-07T10:59:00+02:00" } ] diff --git a/tests/components/notion/conftest.py b/tests/components/notion/conftest.py index 77efdacf9431515d1740a6895516987fb526cef0..7d87b9adc6475e7ff2cdc4cce43cb9e8ff7cb1ac 100644 --- a/tests/components/notion/conftest.py +++ b/tests/components/notion/conftest.py @@ -22,9 +22,9 @@ def client_fixture(data_bridge, data_sensor, data_task): @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, unique_id): +def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_USERNAME], data=config) entry.add_to_hass(hass) return entry @@ -38,19 +38,19 @@ def config_fixture(hass): } -@pytest.fixture(name="data_bridge", scope="session") +@pytest.fixture(name="data_bridge", scope="package") def data_bridge_fixture(): """Define bridge data.""" return json.loads(load_fixture("bridge_data.json", "notion")) -@pytest.fixture(name="data_sensor", scope="session") +@pytest.fixture(name="data_sensor", scope="package") def data_sensor_fixture(): """Define sensor data.""" return json.loads(load_fixture("sensor_data.json", "notion")) -@pytest.fixture(name="data_task", scope="session") +@pytest.fixture(name="data_task", scope="package") def data_task_fixture(): """Define task data.""" return json.loads(load_fixture("task_data.json", "notion")) @@ -65,9 +65,3 @@ async def setup_notion_fixture(hass, client, config): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "user@host.com" diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index 39d2777462f48a0e0c28be1c9a52fe0ea783cecb..d8b5abcc781c1c14c5a421a17f8dd13ffa545f0a 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -7,105 +7,120 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry async def test_entry_diagnostics(hass, config_entry, hass_client, setup_notion): """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "bridges": { - "12345": { - "id": 12345, - "name": None, - "mode": "home", - "hardware_id": "0x1234567890abcdef", - "hardware_revision": 4, - "firmware_version": { - "wifi": "0.121.0", - "wifi_app": "3.3.0", - "silabs": "1.0.1", - }, - "missing_at": None, - "created_at": "2019-04-30T01:43:50.497Z", - "updated_at": "2019-04-30T01:44:43.749Z", - "system_id": 12345, - "firmware": { - "wifi": "0.121.0", - "wifi_app": "3.3.0", - "silabs": "1.0.1", - }, - "links": {"system": 12345}, - } + "entry": { + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "notion", + "title": REDACTED, + "data": {"username": REDACTED, "password": REDACTED}, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, - "sensors": { - "123456": { - "id": 123456, - "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "user": {"id": 12345, "email": REDACTED}, - "bridge": {"id": 12345, "hardware_id": "0x1234567890abcdef"}, - "last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "name": "Bathroom Sensor", - "location_id": 123456, - "system_id": 12345, - "hardware_id": "0x1234567890abcdef", - "firmware_version": "1.1.2", - "hardware_revision": 5, - "device_key": REDACTED, - "encryption_key": True, - "installed_at": "2019-04-30T01:57:34.443Z", - "calibrated_at": "2019-04-30T01:57:35.651Z", - "last_reported_at": "2019-04-30T02:20:04.821Z", - "missing_at": None, - "updated_at": "2019-04-30T01:57:36.129Z", - "created_at": "2019-04-30T01:56:45.932Z", - "signal_strength": 5, - "links": {"location": 123456}, - "lqi": 0, - "rssi": -46, - "surface_type": None, + "data": { + "bridges": { + "12345": { + "id": 12345, + "name": None, + "mode": "home", + "hardware_id": REDACTED, + "hardware_revision": 4, + "firmware_version": { + "wifi": "0.121.0", + "wifi_app": "3.3.0", + "silabs": "1.0.1", + }, + "missing_at": None, + "created_at": "2019-04-30T01:43:50.497Z", + "updated_at": "2019-04-30T01:44:43.749Z", + "system_id": 12345, + "firmware": { + "wifi": "0.121.0", + "wifi_app": "3.3.0", + "silabs": "1.0.1", + }, + "links": {"system": 12345}, + } }, - "132462": { - "id": 132462, - "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "user": {"id": 12345, "email": REDACTED}, - "bridge": {"id": 12345, "hardware_id": "0x1234567890abcdef"}, - "last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "name": "Living Room Sensor", - "location_id": 123456, - "system_id": 12345, - "hardware_id": "0x1234567890abcdef", - "firmware_version": "1.1.2", - "hardware_revision": 5, - "device_key": REDACTED, - "encryption_key": True, - "installed_at": "2019-04-30T01:45:56.169Z", - "calibrated_at": "2019-04-30T01:46:06.256Z", - "last_reported_at": "2019-04-30T02:20:04.829Z", - "missing_at": None, - "updated_at": "2019-04-30T01:46:07.717Z", - "created_at": "2019-04-30T01:45:14.148Z", - "signal_strength": 5, - "links": {"location": 123456}, - "lqi": 0, - "rssi": -30, - "surface_type": None, + "sensors": { + "123456": { + "id": 123456, + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "user": {"id": 12345, "email": REDACTED}, + "bridge": {"id": 12345, "hardware_id": REDACTED}, + "last_bridge_hardware_id": REDACTED, + "name": "Bathroom Sensor", + "location_id": 123456, + "system_id": 12345, + "hardware_id": REDACTED, + "firmware_version": "1.1.2", + "hardware_revision": 5, + "device_key": REDACTED, + "encryption_key": True, + "installed_at": "2019-04-30T01:57:34.443Z", + "calibrated_at": "2019-04-30T01:57:35.651Z", + "last_reported_at": "2019-04-30T02:20:04.821Z", + "missing_at": None, + "updated_at": "2019-04-30T01:57:36.129Z", + "created_at": "2019-04-30T01:56:45.932Z", + "signal_strength": 5, + "links": {"location": 123456}, + "lqi": 0, + "rssi": -46, + "surface_type": None, + }, + "132462": { + "id": 132462, + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "user": {"id": 12345, "email": REDACTED}, + "bridge": {"id": 12345, "hardware_id": REDACTED}, + "last_bridge_hardware_id": REDACTED, + "name": "Living Room Sensor", + "location_id": 123456, + "system_id": 12345, + "hardware_id": REDACTED, + "firmware_version": "1.1.2", + "hardware_revision": 5, + "device_key": REDACTED, + "encryption_key": True, + "installed_at": "2019-04-30T01:45:56.169Z", + "calibrated_at": "2019-04-30T01:46:06.256Z", + "last_reported_at": "2019-04-30T02:20:04.829Z", + "missing_at": None, + "updated_at": "2019-04-30T01:46:07.717Z", + "created_at": "2019-04-30T01:45:14.148Z", + "signal_strength": 5, + "links": {"location": 123456}, + "lqi": 0, + "rssi": -30, + "surface_type": None, + }, }, - }, - "tasks": { - "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "task_type": "low_battery", - "sensor_data": [], - "status": { - "insights": { - "primary": { - "from_state": None, - "to_state": "high", - "data_received_at": "2020-11-17T18:40:27.024Z", - "origin": {}, + "tasks": { + "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "task_type": "low_battery", + "sensor_data": [], + "status": { + "insights": { + "primary": { + "from_state": None, + "to_state": "high", + "data_received_at": "2020-11-17T18:40:27.024Z", + "origin": {}, + } } - } - }, - "created_at": "2020-11-17T18:40:27.024Z", - "updated_at": "2020-11-17T18:40:27.033Z", - "sensor_id": 525993, - "model_version": "4.1", - "configuration": {}, - "links": {"sensor": 525993}, - } + }, + "created_at": "2020-11-17T18:40:27.024Z", + "updated_at": "2020-11-17T18:40:27.033Z", + "sensor_id": 525993, + "model_version": "4.1", + "configuration": {}, + "links": {"sensor": 525993}, + } + }, }, } diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 8d7f8a91ae8b80b0f2e216fb43ca75ebddeb965e..98b30616952a8351809ebc531028e48ad8d53080 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.common import mock_restore_cache_with_extra_data @@ -435,7 +435,7 @@ async def test_deprecated_methods( "native_min_value, state_min_value, native_step, state_step", [ ( - IMPERIAL_SYSTEM, + US_CUSTOMARY_SYSTEM, TEMP_FAHRENHEIT, TEMP_FAHRENHEIT, 100, @@ -450,7 +450,7 @@ async def test_deprecated_methods( 3, ), ( - IMPERIAL_SYSTEM, + US_CUSTOMARY_SYSTEM, TEMP_CELSIUS, TEMP_FAHRENHEIT, 38, diff --git a/tests/components/number/test_recorder.py b/tests/components/number/test_recorder.py index f51d3933b5d4c3501f8658a3752a88cc302d17be..9713dc81d85764c9069d12d42755b15420b2f7b0 100644 --- a/tests/components/number/test_recorder.py +++ b/tests/components/number/test_recorder.py @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test number registered attributes to be excluded.""" await async_setup_component( hass, number.DOMAIN, {number.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/nut/fixtures/CP1500PFCLCD.json b/tests/components/nut/fixtures/CP1500PFCLCD.json index 3a42a01b054495ce717236cc4c9439d663311686..f3121b147acc49b496180246508cde14966f1bf6 100644 --- a/tests/components/nut/fixtures/CP1500PFCLCD.json +++ b/tests/components/nut/fixtures/CP1500PFCLCD.json @@ -5,7 +5,7 @@ "driver.parameter.pollfreq": "30", "ups.beeper.status": "disabled", "input.voltage.nominal": "120", - "device.serial": "000000000000", + "device.serial": "000000000000 ", "ups.timer.shutdown": "-60", "input.voltage": "122.0", "ups.status": "OL", diff --git a/tests/components/nws/test_sensor.py b/tests/components/nws/test_sensor.py index 78d395755222a6eeb77f4de49b9a0ee5f0273b26..9597618ccc8b567c29f7f9888fb459a7ea18d534 100644 --- a/tests/components/nws/test_sensor.py +++ b/tests/components/nws/test_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .const import ( EXPECTED_FORECAST_IMPERIAL, @@ -24,7 +24,7 @@ from tests.common import MockConfigEntry "units,result_observation,result_forecast", [ ( - IMPERIAL_SYSTEM, + US_CUSTOMARY_SYSTEM, SENSOR_EXPECTED_OBSERVATION_IMPERIAL, EXPECTED_FORECAST_IMPERIAL, ), diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 3f3e9a649f3ebd923fcf045e97292544fa23e5a0..b3a9a4bc9f15cd07cfd148aa84bd39fd3d078675 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -15,7 +15,7 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .const import ( EXPECTED_FORECAST_IMPERIAL, @@ -34,7 +34,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed "units,result_observation,result_forecast", [ ( - IMPERIAL_SYSTEM, + US_CUSTOMARY_SYSTEM, WEATHER_EXPECTED_OBSERVATION_IMPERIAL, EXPECTED_FORECAST_IMPERIAL, ), diff --git a/tests/components/octoprint/test_camera.py b/tests/components/octoprint/test_camera.py new file mode 100644 index 0000000000000000000000000000000000000000..2badf1285ceafab5159ab4b82b1ffa33e8c68741 --- /dev/null +++ b/tests/components/octoprint/test_camera.py @@ -0,0 +1,67 @@ +"""The tests for Octoptint camera module.""" + +from unittest.mock import patch + +from pyoctoprintapi import WebcamSettings + +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.helpers import entity_registry as er + +from . import init_integration + + +async def test_camera(hass): + """Test the underlying camera.""" + with patch( + "pyoctoprintapi.OctoprintClient.get_webcam_info", + return_value=WebcamSettings( + base_url="http://fake-octoprint/", + raw={ + "streamUrl": "/webcam/?action=stream", + "snapshotUrl": "http://127.0.0.1:8080/?action=snapshot", + "webcamEnabled": True, + }, + ), + ): + await init_integration(hass, CAMERA_DOMAIN) + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("camera.octoprint_camera") + assert entry is not None + assert entry.unique_id == "uuid" + + +async def test_camera_disabled(hass): + """Test that the camera does not load if there is not one configured.""" + with patch( + "pyoctoprintapi.OctoprintClient.get_webcam_info", + return_value=WebcamSettings( + base_url="http://fake-octoprint/", + raw={ + "streamUrl": "/webcam/?action=stream", + "snapshotUrl": "http://127.0.0.1:8080/?action=snapshot", + "webcamEnabled": False, + }, + ), + ): + await init_integration(hass, CAMERA_DOMAIN) + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("camera.octoprint_camera") + assert entry is None + + +async def test_no_supported_camera(hass): + """Test that the camera does not load if there is not one configured.""" + with patch( + "pyoctoprintapi.OctoprintClient.get_webcam_info", + return_value=None, + ): + await init_integration(hass, CAMERA_DOMAIN) + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("camera.octoprint_camera") + assert entry is None diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index e9de98206d1a344ae20a7714ba0caa1440406cb6..b4e6c5b06662a7def54fa7d88878aeff786bd2bc 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -533,3 +533,55 @@ async def test_duplicate_ssdp_ignored(hass: HomeAssistant) -> None: ) assert result["type"] == "abort" assert result["reason"] == "already_configured" + + +async def test_reauth_form(hass): + """Test we get the form.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "testuser", + "host": "1.1.1.1", + "name": "Printer", + "port": 81, + "ssl": True, + "path": "/", + }, + unique_id="1234", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "entry_id": entry.entry_id, + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + assert result["type"] == "form" + assert not result["errors"] + + with patch( + "pyoctoprintapi.OctoprintClient.request_app_key", return_value="test-key" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "testuser", + }, + ) + await hass.async_block_till_done() + assert result["type"] == "progress" + + with patch( + "homeassistant.components.octoprint.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 204eb6bf77291b4732c92ff9aff4dbef617de054..40d889185ddbafc943ba60dd035b3d2db12f58b6 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -57,6 +57,19 @@ 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"}) + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [], + "checks": [], + }, + }, + ) with patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), patch( "homeassistant.components.hassio.HassIO.is_connected", return_value=True, diff --git a/tests/components/oncue/__init__.py b/tests/components/oncue/__init__.py index 32845aa8d2633a40c411fbdc69f96c1ae7fdf0ba..2ddaf1987f83061c044c020e8aa45795c7a1274d 100644 --- a/tests/components/oncue/__init__.py +++ b/tests/components/oncue/__init__.py @@ -533,6 +533,270 @@ MOCK_ASYNC_FETCH_ALL_OFFLINE_DEVICE = { ) } +MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE = { + "456789": OncueDevice( + name="My Generator", + state="Off", + product_name="RDC 2.4", + hardware_version="319", + serial_number="SERIAL", + sensors={ + "Product": OncueSensor( + name="Product", + display_name="Controller Type", + value="--", + display_value="RDC 2.4", + unit=None, + ), + "FirmwareVersion": OncueSensor( + name="FirmwareVersion", + display_name="Current Firmware", + value="--", + display_value="2.0.6", + unit=None, + ), + "LatestFirmware": OncueSensor( + name="LatestFirmware", + display_name="Latest Firmware", + value="--", + display_value="2.0.6", + unit=None, + ), + "EngineSpeed": OncueSensor( + name="EngineSpeed", + display_name="Engine Speed", + value="--", + display_value="0 R/min", + unit="R/min", + ), + "EngineTargetSpeed": OncueSensor( + name="EngineTargetSpeed", + display_name="Engine Target Speed", + value="--", + display_value="0 R/min", + unit="R/min", + ), + "EngineOilPressure": OncueSensor( + name="EngineOilPressure", + display_name="Engine Oil Pressure", + value="--", + display_value="0 Psi", + unit="Psi", + ), + "EngineCoolantTemperature": OncueSensor( + name="EngineCoolantTemperature", + display_name="Engine Coolant Temperature", + value="--", + display_value="32 F", + unit="F", + ), + "BatteryVoltage": OncueSensor( + name="BatteryVoltage", + display_name="Battery Voltage", + value="0.0", + display_value="13.4 V", + unit="V", + ), + "LubeOilTemperature": OncueSensor( + name="LubeOilTemperature", + display_name="Lube Oil Temperature", + value="--", + display_value="32 F", + unit="F", + ), + "GensetControllerTemperature": OncueSensor( + name="GensetControllerTemperature", + display_name="Generator Controller Temperature", + value="--", + display_value="84.2 F", + unit="F", + ), + "EngineCompartmentTemperature": OncueSensor( + name="EngineCompartmentTemperature", + display_name="Engine Compartment Temperature", + value="--", + display_value="62.6 F", + unit="F", + ), + "GeneratorTrueTotalPower": OncueSensor( + name="GeneratorTrueTotalPower", + display_name="Generator True Total Power", + value="--", + display_value="0.0 W", + unit="W", + ), + "GeneratorTruePercentOfRatedPower": OncueSensor( + name="GeneratorTruePercentOfRatedPower", + display_name="Generator True Percent Of Rated Power", + value="--", + display_value="0 %", + unit="%", + ), + "GeneratorVoltageAB": OncueSensor( + name="GeneratorVoltageAB", + display_name="Generator Voltage AB", + value="--", + display_value="0.0 V", + unit="V", + ), + "GeneratorVoltageAverageLineToLine": OncueSensor( + name="GeneratorVoltageAverageLineToLine", + display_name="Generator Voltage Average Line To Line", + value="--", + display_value="0.0 V", + unit="V", + ), + "GeneratorCurrentAverage": OncueSensor( + name="GeneratorCurrentAverage", + display_name="Generator Current Average", + value="--", + display_value="0.0 A", + unit="A", + ), + "GeneratorFrequency": OncueSensor( + name="GeneratorFrequency", + display_name="Generator Frequency", + value="--", + display_value="0.0 Hz", + unit="Hz", + ), + "GensetSerialNumber": OncueSensor( + name="GensetSerialNumber", + display_name="Generator Serial Number", + value="--", + display_value="33FDGMFR0026", + unit=None, + ), + "GensetState": OncueSensor( + name="GensetState", + display_name="Generator State", + value="--", + display_value="Off", + unit=None, + ), + "GensetControllerSerialNumber": OncueSensor( + name="GensetControllerSerialNumber", + display_name="Generator Controller Serial Number", + value="--", + display_value="-1", + unit=None, + ), + "GensetModelNumberSelect": OncueSensor( + name="GensetModelNumberSelect", + display_name="Genset Model Number Select", + value="--", + display_value="38 RCLB", + unit=None, + ), + "GensetControllerClockTime": OncueSensor( + name="GensetControllerClockTime", + display_name="Generator Controller Clock Time", + value="--", + display_value="2022-01-13 18:08:13", + unit=None, + ), + "GensetControllerTotalOperationTime": OncueSensor( + name="GensetControllerTotalOperationTime", + display_name="Generator Controller Total Operation Time", + value="--", + display_value="16770.8 h", + unit="h", + ), + "EngineTotalRunTime": OncueSensor( + name="EngineTotalRunTime", + display_name="Engine Total Run Time", + value="--", + display_value="28.1 h", + unit="h", + ), + "EngineTotalRunTimeLoaded": OncueSensor( + name="EngineTotalRunTimeLoaded", + display_name="Engine Total Run Time Loaded", + value="--", + display_value="5.5 h", + unit="h", + ), + "EngineTotalNumberOfStarts": OncueSensor( + name="EngineTotalNumberOfStarts", + display_name="Engine Total Number Of Starts", + value="--", + display_value="101", + unit=None, + ), + "GensetTotalEnergy": OncueSensor( + name="GensetTotalEnergy", + display_name="Genset Total Energy", + value="--", + display_value="1.2022309E7 kWh", + unit="kWh", + ), + "AtsContactorPosition": OncueSensor( + name="AtsContactorPosition", + display_name="Ats Contactor Position", + value="--", + display_value="Source1", + unit=None, + ), + "AtsSourcesAvailable": OncueSensor( + name="AtsSourcesAvailable", + display_name="Ats Sources Available", + value="--", + display_value="Source1", + unit=None, + ), + "Source1VoltageAverageLineToLine": OncueSensor( + name="Source1VoltageAverageLineToLine", + display_name="Source1 Voltage Average Line To Line", + value="--", + display_value="253.5 V", + unit="V", + ), + "Source2VoltageAverageLineToLine": OncueSensor( + name="Source2VoltageAverageLineToLine", + display_name="Source2 Voltage Average Line To Line", + value="--", + display_value="0.0 V", + unit="V", + ), + "IPAddress": OncueSensor( + name="IPAddress", + display_name="IP Address", + value="--", + display_value="1.2.3.4:1026", + unit=None, + ), + "MacAddress": OncueSensor( + name="MacAddress", + display_name="Mac Address", + value="--", + display_value="--", + unit=None, + ), + "ConnectedServerIPAddress": OncueSensor( + name="ConnectedServerIPAddress", + display_name="Connected Server IP Address", + value="--", + display_value="40.117.195.28", + unit=None, + ), + "NetworkConnectionEstablished": OncueSensor( + name="NetworkConnectionEstablished", + display_name="Network Connection Established", + value="--", + display_value="True", + unit=None, + ), + "SerialNumber": OncueSensor( + name="SerialNumber", + display_name="Serial Number", + value="--", + display_value="1073879692", + unit=None, + ), + }, + ) +} + def _patch_login_and_data(): @contextmanager @@ -556,3 +820,28 @@ def _patch_login_and_data_offline_device(): yield return _patcher() + + +def _patch_login_and_data_unavailable(): + @contextmanager + def _patcher(): + with patch("homeassistant.components.oncue.Oncue.async_login"), patch( + "homeassistant.components.oncue.Oncue.async_fetch_all", + return_value=MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE, + ): + yield + + return _patcher() + + +def _patch_login_and_data_unavailable_device(): + @contextmanager + def _patcher(): + + with patch("homeassistant.components.oncue.Oncue.async_login"), patch( + "homeassistant.components.oncue.Oncue.async_fetch_all", + return_value=MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE, + ): + yield + + return _patcher() diff --git a/tests/components/oncue/test_binary_sensor.py b/tests/components/oncue/test_binary_sensor.py index 020b914c76bac063714783c8fc918d2b6e8dbb15..f2e7657089f8641a34fbfc4544dfe2e247c22f78 100644 --- a/tests/components/oncue/test_binary_sensor.py +++ b/tests/components/oncue/test_binary_sensor.py @@ -4,11 +4,11 @@ from __future__ import annotations from homeassistant.components import oncue from homeassistant.components.oncue.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_ON +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import _patch_login_and_data +from . import _patch_login_and_data, _patch_login_and_data_unavailable from tests.common import MockConfigEntry @@ -33,3 +33,25 @@ async def test_binary_sensors(hass: HomeAssistant) -> None: ).state == STATE_ON ) + + +async def test_binary_sensors_not_unavailable(hass: HomeAssistant) -> None: + """Test the network connection established binary sensor is available when connection status is false.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with _patch_login_and_data_unavailable(): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + assert len(hass.states.async_all("binary_sensor")) == 1 + assert ( + hass.states.get( + "binary_sensor.my_generator_network_connection_established" + ).state + == STATE_OFF + ) diff --git a/tests/components/oncue/test_sensor.py b/tests/components/oncue/test_sensor.py index 60c9f68f81b2b8d4add19360e76644123bb4bf90..6319bcdd9f9bc27702847ef1fc38d4be66416b93 100644 --- a/tests/components/oncue/test_sensor.py +++ b/tests/components/oncue/test_sensor.py @@ -6,12 +6,17 @@ import pytest from homeassistant.components import oncue from homeassistant.components.oncue.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from . import _patch_login_and_data, _patch_login_and_data_offline_device +from . import ( + _patch_login_and_data, + _patch_login_and_data_offline_device, + _patch_login_and_data_unavailable, + _patch_login_and_data_unavailable_device, +) from tests.common import MockConfigEntry @@ -141,3 +146,159 @@ async def test_sensors(hass: HomeAssistant, patcher, connections) -> None: assert ( hass.states.get("sensor.my_generator_generator_current_average").state == "0.0" ) + + +@pytest.mark.parametrize( + "patcher, connections", + [ + [_patch_login_and_data_unavailable_device, set()], + [_patch_login_and_data_unavailable, {("mac", "c9:24:22:6f:14:00")}], + ], +) +async def test_sensors_unavailable(hass: HomeAssistant, patcher, connections) -> None: + """Test that the sensors are unavailable.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with patcher(): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + assert len(hass.states.async_all("sensor")) == 25 + assert ( + hass.states.get("sensor.my_generator_latest_firmware").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_speed").state == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_oil_pressure").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_coolant_temperature").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_battery_voltage").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_lube_oil_temperature").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_generator_controller_temperature").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_compartment_temperature").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_generator_true_total_power").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get( + "sensor.my_generator_generator_true_percent_of_rated_power" + ).state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get( + "sensor.my_generator_generator_voltage_average_line_to_line" + ).state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_generator_frequency").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_generator_state").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get( + "sensor.my_generator_generator_controller_total_operation_time" + ).state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_total_run_time").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_ats_contactor_position").state + == STATE_UNAVAILABLE + ) + + assert hass.states.get("sensor.my_generator_ip_address").state == STATE_UNAVAILABLE + + assert ( + hass.states.get("sensor.my_generator_connected_server_ip_address").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_target_speed").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_engine_total_run_time_loaded").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get( + "sensor.my_generator_source1_voltage_average_line_to_line" + ).state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get( + "sensor.my_generator_source2_voltage_average_line_to_line" + ).state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_genset_total_energy").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get("sensor.my_generator_engine_total_number_of_starts").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get("sensor.my_generator_generator_current_average").state + == STATE_UNAVAILABLE + ) + + assert ( + hass.states.get("sensor.my_generator_battery_voltage").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/openexchangerates/test_config_flow.py b/tests/components/openexchangerates/test_config_flow.py index ee4ba57de2c9e0c28c70c814962293d266634a6f..213badcab087181700cd6acd08ab3da8125e8d3f 100644 --- a/tests/components/openexchangerates/test_config_flow.py +++ b/tests/components/openexchangerates/test_config_flow.py @@ -237,32 +237,3 @@ async def test_reauth( assert result["type"] == "abort" assert result["reason"] == "reauth_successful" assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_create_entry( - hass: HomeAssistant, - mock_latest_rates_config_flow: AsyncMock, - mock_setup_entry: AsyncMock, -) -> None: - """Test we can import data from configuration.yaml.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "api_key": "test-api-key", - "base": "USD", - "quote": "EUR", - "name": "test", - }, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "USD" - assert result["data"] == { - "api_key": "test-api-key", - "base": "USD", - "quote": "EUR", - "name": "test", - } - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/openuv/conftest.py b/tests/components/openuv/conftest.py index c39a84b8b4c3766bd287ad14b56fc1db5013c7ea..3caa41749ee53fe37531bdd878e46a2446bcb948 100644 --- a/tests/components/openuv/conftest.py +++ b/tests/components/openuv/conftest.py @@ -17,11 +17,11 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, unique_id): +def config_entry_fixture(hass, config): """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id=unique_id, + unique_id=f"{config[CONF_LATITUDE]}, {config[CONF_LONGITUDE]}", data=config, options={CONF_FROM_WINDOW: 3.5, CONF_TO_WINDOW: 3.5}, ) @@ -40,13 +40,13 @@ def config_fixture(hass): } -@pytest.fixture(name="data_protection_window", scope="session") +@pytest.fixture(name="data_protection_window", scope="package") def data_protection_window_fixture(): """Define a fixture to return UV protection window data.""" return json.loads(load_fixture("protection_window_data.json", "openuv")) -@pytest.fixture(name="data_uv_index", scope="session") +@pytest.fixture(name="data_uv_index", scope="package") def data_uv_index_fixture(): """Define a fixture to return UV index data.""" return json.loads(load_fixture("uv_index_data.json", "openuv")) @@ -68,9 +68,3 @@ async def setup_openuv_fixture(hass, config, data_protection_window, data_uv_ind assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "51.528308, -0.3817765" diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index 1196045300b3d269c780e092d6d7b6e25ac5e9a0..84e8a691255b6df6c3fd4c09ce54d1816d9f1eff 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -1,29 +1,39 @@ """Test OpenUV diagnostics.""" from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.openuv import CONF_ENTRY_ID +from homeassistant.setup import async_setup_component from tests.components.diagnostics import get_diagnostics_for_config_entry async def test_entry_diagnostics(hass, config_entry, hass_client, setup_openuv): """Test config entry diagnostics.""" - await hass.services.async_call( - "openuv", "update_data", service_data={CONF_ENTRY_ID: "test_entry_id"} - ) + await async_setup_component(hass, "homeassistant", {}) assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "openuv", + "title": REDACTED, "data": { "api_key": REDACTED, "elevation": 0, "latitude": REDACTED, "longitude": REDACTED, }, - "options": { - "from_window": 3.5, - "to_window": 3.5, - }, + "options": {"from_window": 3.5, "to_window": 3.5}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { + "protection_window": { + "from_time": "2018-07-30T15:17:49.750Z", + "from_uv": 3.2509, + "to_time": "2018-07-30T22:47:49.750Z", + "to_uv": 3.6483, + }, "uv": { "uv": 8.2342, "uv_time": "2018-07-30T20:53:06.302Z", @@ -62,11 +72,5 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_openuv): }, }, }, - "protection_window": { - "from_time": "2018-07-30T15:17:49.750Z", - "from_uv": 3.2509, - "to_time": "2018-07-30T22:47:49.750Z", - "to_uv": 3.6483, - }, }, } diff --git a/tests/components/oralb/__init__.py b/tests/components/oralb/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5525a859f21c800cd71054c5e4b0c357894a21ab --- /dev/null +++ b/tests/components/oralb/__init__.py @@ -0,0 +1,35 @@ +"""Tests for the OralB integration.""" + + +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +NOT_ORALB_SERVICE_INFO = BluetoothServiceInfo( + name="Not it", + address="61DE521B-F0BF-9F44-64D4-75BBE1738105", + rssi=-63, + manufacturer_data={3234: b"\x00\x01"}, + service_data={}, + service_uuids=[], + source="local", +) + +ORALB_SERVICE_INFO = BluetoothServiceInfo( + name="78:DB:2F:C2:48:BE", + address="78:DB:2F:C2:48:BE", + rssi=-63, + manufacturer_data={220: b"\x02\x01\x08\x03\x00\x00\x00\x01\x01\x00\x04"}, + service_uuids=[], + service_data={}, + source="local", +) + + +ORALB_IO_SERIES_4_SERVICE_INFO = BluetoothServiceInfo( + name="GXB772CD\x00\x00\x00\x00\x00\x00\x00\x00\x00", + address="78:DB:2F:C2:48:BE", + rssi=-63, + manufacturer_data={220: b"\x074\x0c\x038\x00\x00\x02\x01\x00\x04"}, + service_uuids=[], + service_data={}, + source="local", +) diff --git a/tests/components/oralb/conftest.py b/tests/components/oralb/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..454cb7af726a5f71e7d19ac15acdacaebefd57d1 --- /dev/null +++ b/tests/components/oralb/conftest.py @@ -0,0 +1,8 @@ +"""OralB session fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/oralb/test_config_flow.py b/tests/components/oralb/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..cb7f97a50898c82560e9226f576311c20048e08b --- /dev/null +++ b/tests/components/oralb/test_config_flow.py @@ -0,0 +1,211 @@ +"""Test the OralB config flow.""" + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.oralb.const import DOMAIN +from homeassistant.data_entry_flow import FlowResultType + +from . import NOT_ORALB_SERVICE_INFO, ORALB_IO_SERIES_4_SERVICE_INFO, ORALB_SERVICE_INFO + +from tests.common import MockConfigEntry + + +async def test_async_step_bluetooth_valid_device(hass): + """Test discovery via bluetooth with a valid device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=ORALB_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Smart Series 7000 48BE" + assert result2["data"] == {} + assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" + + +async def test_async_step_bluetooth_valid_io_series4_device(hass): + """Test discovery via bluetooth with a valid device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=ORALB_IO_SERIES_4_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "IO Series 4 48BE" + assert result2["data"] == {} + assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" + + +async def test_async_step_bluetooth_not_oralb(hass): + """Test discovery via bluetooth not oralb.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=NOT_ORALB_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_supported" + + +async def test_async_step_user_no_devices_found(hass): + """Test setup from service info cache with no devices found.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_user_with_found_devices(hass): + """Test setup from service info cache with devices found.""" + with patch( + "homeassistant.components.oralb.config_flow.async_discovered_service_info", + return_value=[ORALB_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "78:DB:2F:C2:48:BE"}, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Smart Series 7000 48BE" + assert result2["data"] == {} + assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" + + +async def test_async_step_user_device_added_between_steps(hass): + """Test the device gets added via another flow between steps.""" + with patch( + "homeassistant.components.oralb.config_flow.async_discovered_service_info", + return_value=[ORALB_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="78:DB:2F:C2:48:BE", + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "78:DB:2F:C2:48:BE"}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_async_step_user_with_found_devices_already_setup(hass): + """Test setup from service info cache with devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="78:DB:2F:C2:48:BE", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.oralb.config_flow.async_discovered_service_info", + return_value=[ORALB_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_bluetooth_devices_already_setup(hass): + """Test we can't start a flow if there is already a config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="78:DB:2F:C2:48:BE", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=ORALB_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_async_step_bluetooth_already_in_progress(hass): + """Test we can't start a flow for the same device twice.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=ORALB_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=ORALB_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_async_step_user_takes_precedence_over_discovery(hass): + """Test manual setup takes precedence over discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=ORALB_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + with patch( + "homeassistant.components.oralb.config_flow.async_discovered_service_info", + return_value=[ORALB_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "78:DB:2F:C2:48:BE"}, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Smart Series 7000 48BE" + assert result2["data"] == {} + assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" + + # Verify the original one was aborted + assert not hass.config_entries.flow.async_progress(DOMAIN) diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..2122ad9bbff80382cedf15c662d71a822ef9417e --- /dev/null +++ b/tests/components/oralb/test_sensor.py @@ -0,0 +1,65 @@ +"""Test the OralB sensors.""" + + +from homeassistant.components.oralb.const import DOMAIN +from homeassistant.const import ATTR_FRIENDLY_NAME + +from . import ORALB_IO_SERIES_4_SERVICE_INFO, ORALB_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_sensors(hass, entity_registry_enabled_by_default): + """Test setting up creates the sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ORALB_SERVICE_INFO.address, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 0 + inject_bluetooth_service_info(hass, ORALB_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 8 + + toothbrush_sensor = hass.states.get( + "sensor.smart_series_7000_48be_toothbrush_state" + ) + toothbrush_sensor_attrs = toothbrush_sensor.attributes + assert toothbrush_sensor.state == "running" + assert ( + toothbrush_sensor_attrs[ATTR_FRIENDLY_NAME] + == "Smart Series 7000 48BE Toothbrush State" + ) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sensors_io_series_4(hass, entity_registry_enabled_by_default): + """Test setting up creates the sensors with an io series 4.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ORALB_IO_SERIES_4_SERVICE_INFO.address, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 0 + inject_bluetooth_service_info(hass, ORALB_IO_SERIES_4_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 8 + + toothbrush_sensor = hass.states.get("sensor.io_series_4_48be_mode") + toothbrush_sensor_attrs = toothbrush_sensor.attributes + assert toothbrush_sensor.state == "gum care" + assert toothbrush_sensor_attrs[ATTR_FRIENDLY_NAME] == "IO Series 4 48BE Mode" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 940da7b39c2d67f720d333dee27e349ded81a757..dc50896626d0ce01a24c96d1cdbc9946df1e65d5 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -27,6 +27,7 @@ TEST_PASSWORD = "test-password" TEST_PASSWORD2 = "test-password2" TEST_HUB = "somfy_europe" TEST_HUB2 = "hi_kumo_europe" +TEST_HUB_COZYTOUCH = "atlantic_cozytouch" TEST_GATEWAY_ID = "1234-5678-9123" TEST_GATEWAY_ID2 = "4321-5678-9123" @@ -89,7 +90,7 @@ async def test_form(hass: HomeAssistant) -> None: (ClientError, "cannot_connect"), (MaintenanceException, "server_in_maintenance"), (TooManyAttemptsBannedException, "too_many_attempts"), - (UnknownUserException, "unknown_user"), + (UnknownUserException, "unsupported_hardware"), (Exception, "unknown"), ], ) @@ -112,6 +113,35 @@ async def test_form_invalid_auth( assert result2["errors"] == {"base": error} +@pytest.mark.parametrize( + "side_effect, error", + [ + (BadCredentialsException, "unsupported_hardware"), + ], +) +async def test_form_invalid_cozytouch_auth( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we handle invalid auth from CozyTouch.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_HUB_COZYTOUCH, + }, + ) + + assert result["step_id"] == config_entries.SOURCE_USER + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": error} + + async def test_abort_on_duplicate_entry(hass: HomeAssistant) -> None: """Test config flow aborts Config Flow on duplicate entries.""" MockConfigEntry( diff --git a/tests/components/plant/test_init.py b/tests/components/plant/test_init.py index 40d2930ce939117f10c5a49190e89b0206541ca8..d143bbef00df42d2edeadc7d15fe223e6072d344 100644 --- a/tests/components/plant/test_init.py +++ b/tests/components/plant/test_init.py @@ -144,7 +144,7 @@ async def test_state_problem_if_unavailable(hass): assert state.attributes[plant.READING_MOISTURE] == STATE_UNAVAILABLE -async def test_load_from_db(hass, recorder_mock): +async def test_load_from_db(recorder_mock, hass): """Test bootstrapping the brightness history from the database. This test can should only be executed if the loading of the history diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index fb5a0f067242c9d86597c50c4dacd4bb5042703b..8c25baa8746aea6981255c35507c9699c32365d6 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -766,6 +766,62 @@ async def test_trigger_reauth( assert entry.data[PLEX_SERVER_CONFIG][CONF_TOKEN] == "BRAND_NEW_TOKEN" +async def test_trigger_reauth_multiple_servers_available( + hass, + entry, + mock_plex_server, + mock_websocket, + current_request_with_host, + requests_mock, + plextv_resources_two_servers, +): + """Test setup and reauthorization of a Plex token when multiple servers are available.""" + assert entry.state is ConfigEntryState.LOADED + + requests_mock.get( + "https://plex.tv/api/resources", + text=plextv_resources_two_servers, + ) + + with patch( + "plexapi.server.PlexServer.clients", side_effect=plexapi.exceptions.Unauthorized + ), patch("plexapi.server.PlexServer", side_effect=plexapi.exceptions.Unauthorized): + trigger_plex_update(mock_websocket) + await wait_for_debouncer(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is not ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + + flow_id = flows[0]["flow_id"] + + with patch("plexauth.PlexAuth.initiate_auth"), patch( + "plexauth.PlexAuth.token", return_value="BRAND_NEW_TOKEN" + ): + result = await hass.config_entries.flow.async_configure(flow_id, user_input={}) + assert result["type"] == "external" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == "external_done" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == "abort" + assert result["flow_id"] == flow_id + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.flow.async_progress()) == 0 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert entry.state is ConfigEntryState.LOADED + assert entry.data[CONF_SERVER] == mock_plex_server.friendly_name + assert entry.data[CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier + assert entry.data[PLEX_SERVER_CONFIG][CONF_URL] == PLEX_DIRECT_URL + assert entry.data[PLEX_SERVER_CONFIG][CONF_TOKEN] == "BRAND_NEW_TOKEN" + + async def test_client_request_missing(hass): """Test when client headers are not set properly.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index d7941f744509fda4e4dbfe5ba26c79e272d5570f..aa34fc8bed67602527de69366765c2f32e93ac95 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -63,6 +63,7 @@ def mock_smile_config_flow() -> Generator[None, MagicMock, None]: ) as smile_mock: smile = smile_mock.return_value smile.smile_hostname = "smile12345" + smile.smile_model = "Test Model" smile.smile_name = "Test Smile Name" smile.connect.return_value = True yield smile @@ -83,6 +84,7 @@ def mock_smile_adam() -> Generator[None, MagicMock, None]: smile.smile_version = "3.0.15" smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" + smile.smile_model = "Gateway" smile.smile_name = "Adam" smile.connect.return_value = True @@ -108,6 +110,7 @@ def mock_smile_adam_2() -> Generator[None, MagicMock, None]: smile.smile_version = "3.6.4" smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" + smile.smile_model = "Gateway" smile.smile_name = "Adam" smile.connect.return_value = True @@ -133,6 +136,7 @@ def mock_smile_adam_3() -> Generator[None, MagicMock, None]: smile.smile_version = "3.6.4" smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" + smile.smile_model = "Gateway" smile.smile_name = "Adam" smile.connect.return_value = True @@ -157,7 +161,8 @@ def mock_smile_anna() -> Generator[None, MagicMock, None]: smile.smile_version = "4.0.15" smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" - smile.smile_name = "Anna" + smile.smile_model = "Gateway" + smile.smile_name = "Smile Anna" smile.connect.return_value = True @@ -181,7 +186,8 @@ def mock_smile_anna_2() -> Generator[None, MagicMock, None]: smile.smile_version = "4.0.15" smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" - smile.smile_name = "Anna" + smile.smile_model = "Gateway" + smile.smile_name = "Smile Anna" smile.connect.return_value = True @@ -205,7 +211,8 @@ def mock_smile_anna_3() -> Generator[None, MagicMock, None]: smile.smile_version = "4.0.15" smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" - smile.smile_name = "Anna" + smile.smile_model = "Gateway" + smile.smile_name = "Smile Anna" smile.connect.return_value = True @@ -229,6 +236,7 @@ def mock_smile_p1() -> Generator[None, MagicMock, None]: smile.smile_version = "3.3.9" smile.smile_type = "power" smile.smile_hostname = "smile98765" + smile.smile_model = "Gateway" smile.smile_name = "Smile P1" smile.connect.return_value = True @@ -253,6 +261,7 @@ def mock_stretch() -> Generator[None, MagicMock, None]: smile.smile_version = "3.1.11" smile.smile_type = "stretch" smile.smile_hostname = "stretch98765" + smile.smile_model = "Gateway" smile.smile_name = "Stretch" smile.connect.return_value = True diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 08792347af09ecedd5a82c2d14b4a1c8f594b8c2..d62ff0e249d8b1e7d0e32e45f4559b3c57a0bb49 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -26,7 +26,8 @@ "upper_bound": 99.9, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "away", "available_schedules": [ "CV Roan", @@ -53,6 +54,7 @@ "name": "Floor kraan", "zigbee_mac_address": "ABCD012345670A02", "vendor": "Plugwise", + "available": true, "sensors": { "temperature": 26.0, "setpoint": 21.5, @@ -69,6 +71,7 @@ "name": "Bios Cv Thermostatic Radiator ", "zigbee_mac_address": "ABCD012345670A09", "vendor": "Plugwise", + "available": true, "sensors": { "temperature": 17.2, "setpoint": 13.0, @@ -92,7 +95,8 @@ "upper_bound": 99.9, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "home", "available_schedules": [ "CV Roan", @@ -116,12 +120,11 @@ "hardware": "AME Smile 2.0 board", "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", "mac_address": "012345670001", - "model": "Adam", + "model": "Gateway", "name": "Adam", "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise B.V.", + "vendor": "Plugwise", "regulation_mode": "heating", - "regulation_modes": [], "binary_sensors": { "plugwise_notification": true }, @@ -138,6 +141,7 @@ "name": "Thermostatic Radiator Jessie", "zigbee_mac_address": "ABCD012345670A10", "vendor": "Plugwise", + "available": true, "sensors": { "temperature": 17.1, "setpoint": 15.0, @@ -154,6 +158,7 @@ "name": "Playstation Smart Plug", "zigbee_mac_address": "ABCD012345670A12", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 82.6, "electricity_consumed_interval": 8.6, @@ -173,6 +178,7 @@ "name": "CV Pomp", "zigbee_mac_address": "ABCD012345670A05", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 35.6, "electricity_consumed_interval": 7.37, @@ -205,6 +211,7 @@ "name": "NAS", "zigbee_mac_address": "ABCD012345670A14", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 16.5, "electricity_consumed_interval": 0.5, @@ -224,6 +231,7 @@ "name": "USG Smart Plug", "zigbee_mac_address": "ABCD012345670A16", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 8.5, "electricity_consumed_interval": 0.0, @@ -243,6 +251,7 @@ "name": "NVR", "zigbee_mac_address": "ABCD012345670A15", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 34.0, "electricity_consumed_interval": 9.15, @@ -262,6 +271,7 @@ "name": "Fibaro HC2", "zigbee_mac_address": "ABCD012345670A13", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 12.5, "electricity_consumed_interval": 3.8, @@ -288,7 +298,8 @@ "upper_bound": 99.9, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "asleep", "available_schedules": [ "CV Roan", @@ -315,6 +326,7 @@ "name": "Thermostatic Radiator Badkamer", "zigbee_mac_address": "ABCD012345670A17", "vendor": "Plugwise", + "available": true, "sensors": { "temperature": 19.1, "setpoint": 14.0, @@ -338,7 +350,8 @@ "upper_bound": 99.9, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "away", "available_schedules": [ "CV Roan", @@ -364,6 +377,7 @@ "name": "Ziggo Modem", "zigbee_mac_address": "ABCD012345670A01", "vendor": "Plugwise", + "available": true, "sensors": { "electricity_consumed": 12.2, "electricity_consumed_interval": 2.97, @@ -390,7 +404,8 @@ "upper_bound": 100.0, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "no_frost", "available_schedules": [ "CV Roan", diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index 1cc94ca6347b1e47ad70943d4b3bddf153f92755..546a11b2c68df3ee825e978274889310c4acbfee 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -1,6 +1,6 @@ [ { - "smile_name": "Smile", + "smile_name": "Smile Anna", "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", "cooling_present": true, @@ -19,7 +19,7 @@ "upper_bound": 100.0, "resolution": 1.0 }, - "elga_cooling_enabled": true, + "available": true, "binary_sensors": { "dhw_state": false, "heating_state": true, @@ -30,6 +30,7 @@ }, "sensors": { "water_temperature": 29.1, + "dhw_temperature": 46.3, "intended_boiler_temperature": 0.0, "modulation_level": 52, "return_temperature": 25.1, @@ -46,9 +47,9 @@ "hardware": "AME Smile 2.0 board", "location": "a57efe5f145f498c9be62a9b63626fbf", "mac_address": "012345670001", - "model": "Smile", - "name": "Smile", - "vendor": "Plugwise B.V.", + "model": "Gateway", + "name": "Smile Anna", + "vendor": "Plugwise", "binary_sensors": { "plugwise_notification": false }, @@ -61,15 +62,17 @@ "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "model": "Anna", + "model": "ThermoTouch", "name": "Anna", "vendor": "Plugwise", "thermostat": { - "setpoint": 20.5, + "setpoint_low": 20.5, + "setpoint_high": 24.0, "lower_bound": 4.0, "upper_bound": 30.0, "resolution": 0.1 }, + "available": true, "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], "active_preset": "home", "available_schedules": ["standaard"], @@ -78,10 +81,11 @@ "mode": "auto", "sensors": { "temperature": 19.3, - "setpoint": 20.5, "illuminance": 86.0, "cooling_activation_outdoor_temperature": 21.0, - "cooling_deactivation_threshold": 4.0 + "cooling_deactivation_threshold": 4.0, + "setpoint_low": 20.5, + "setpoint_high": 24.0 } } } diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 1f7c82983d42a83630a3ff0f7e981debb73c3248..06a3fa400bfe95cfa1fb1952ad9118114da62841 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -10,23 +10,29 @@ "ad4838d7d35c4d6ea796ee12ae5aedf8": { "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", - "model": "Anna", + "model": "ThermoTouch", "name": "Anna", - "vendor": "Plugwise B.V.", + "vendor": "Plugwise", "thermostat": { - "setpoint": 18.5, + "setpoint_low": 4.0, + "setpoint_high": 23.5, "lower_bound": 1.0, "upper_bound": 35.0, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "asleep", "available_schedules": ["Weekschema", "Badkamer", "Test"], "selected_schedule": "None", "last_used": "Weekschema", "control_state": "cooling", - "mode": "cool", - "sensors": { "temperature": 18.1, "setpoint": 18.5 } + "mode": "heat_cool", + "sensors": { + "temperature": 25.8, + "setpoint_low": 4.0, + "setpoint_high": 23.5 + } }, "1772a4ea304041adb83f357b751341ff": { "dev_class": "thermo_sensor", @@ -37,6 +43,7 @@ "name": "Tom Badkamer", "zigbee_mac_address": "ABCD012345670A01", "vendor": "Plugwise", + "available": true, "sensors": { "temperature": 21.6, "battery": 99, @@ -54,12 +61,14 @@ "zigbee_mac_address": "ABCD012345670A04", "vendor": "Plugwise", "thermostat": { - "setpoint": 15.0, + "setpoint_low": 19.0, + "setpoint_high": 25.0, "lower_bound": 0.0, "upper_bound": 99.9, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "home", "available_schedules": ["Weekschema", "Badkamer", "Test"], "selected_schedule": "Badkamer", @@ -67,9 +76,10 @@ "control_state": "off", "mode": "auto", "sensors": { - "temperature": 17.9, + "temperature": 239, "battery": 56, - "setpoint": 15.0 + "setpoint_low": 20.0, + "setpoint_high": 23.5 } }, "da224107914542988a88561b4452b0f6": { @@ -78,10 +88,10 @@ "hardware": "AME Smile 2.0 board", "location": "bc93488efab249e5bc54fd7e175a6f91", "mac_address": "012345670001", - "model": "Adam", + "model": "Gateway", "name": "Adam", "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise B.V.", + "vendor": "Plugwise", "regulation_mode": "cooling", "regulation_modes": [ "cooling", @@ -94,7 +104,7 @@ "plugwise_notification": false }, "sensors": { - "outdoor_temperature": -1.25 + "outdoor_temperature": 29.65 } }, "056ee145a816487eaa69243c3280f8bf": { @@ -108,7 +118,7 @@ "upper_bound": 95.0, "resolution": 0.01 }, - "adam_cooling_enabled": true, + "available": true, "binary_sensors": { "cooling_state": true, "dhw_state": false, @@ -116,8 +126,8 @@ "flame_state": false }, "sensors": { - "water_temperature": 37.0, - "intended_boiler_temperature": 38.1 + "water_temperature": 19.0, + "intended_boiler_temperature": 17.5 }, "switches": { "dhw_cm_switch": false diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index 0a00a5b7b1cdafde3e221bd5c9d1883e49b86322..8ee3df544e5b30dd2b6a8c7362e37edd118c9012 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -10,23 +10,24 @@ "ad4838d7d35c4d6ea796ee12ae5aedf8": { "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", - "model": "Anna", + "model": "ThermoTouch", "name": "Anna", - "vendor": "Plugwise B.V.", + "vendor": "Plugwise", "thermostat": { - "setpoint": 18.5, + "setpoint": 20.0, "lower_bound": 1.0, "upper_bound": 35.0, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "asleep", "available_schedules": ["Weekschema", "Badkamer", "Test"], "selected_schedule": "None", "last_used": "Weekschema", "control_state": "heating", "mode": "heat", - "sensors": { "temperature": 18.1, "setpoint": 18.5 } + "sensors": { "temperature": 19.1, "setpoint": 20.0 } }, "1772a4ea304041adb83f357b751341ff": { "dev_class": "thermo_sensor", @@ -37,8 +38,9 @@ "name": "Tom Badkamer", "zigbee_mac_address": "ABCD012345670A01", "vendor": "Plugwise", + "available": true, "sensors": { - "temperature": 21.6, + "temperature": 18.6, "battery": 99, "temperature_difference": 2.3, "valve_position": 0.0 @@ -59,7 +61,8 @@ "upper_bound": 99.9, "resolution": 0.01 }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": true, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "home", "available_schedules": ["Weekschema", "Badkamer", "Test"], "selected_schedule": "Badkamer", @@ -78,10 +81,10 @@ "hardware": "AME Smile 2.0 board", "location": "bc93488efab249e5bc54fd7e175a6f91", "mac_address": "012345670001", - "model": "Adam", + "model": "Gateway", "name": "Adam", "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise B.V.", + "vendor": "Plugwise", "regulation_mode": "heating", "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], "binary_sensors": { @@ -108,6 +111,7 @@ "upper_bound": 60.0, "resolution": 0.01 }, + "available": true, "binary_sensors": { "dhw_state": false, "heating_state": true, diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index a9a92126265230e3fc94e306cc878e1ed67ed7a8..6326a02fedb3a7d73dd021b75c192e66c1e0072e 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -1,6 +1,6 @@ [ { - "smile_name": "Smile", + "smile_name": "Smile Anna", "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", "cooling_present": true, @@ -19,7 +19,7 @@ "upper_bound": 100.0, "resolution": 1.0 }, - "elga_cooling_enabled": true, + "available": true, "binary_sensors": { "dhw_state": false, "heating_state": false, @@ -30,6 +30,7 @@ }, "sensors": { "water_temperature": 29.1, + "dhw_temperature": 46.3, "intended_boiler_temperature": 0.0, "modulation_level": 52, "return_temperature": 25.1, @@ -46,9 +47,9 @@ "hardware": "AME Smile 2.0 board", "location": "a57efe5f145f498c9be62a9b63626fbf", "mac_address": "012345670001", - "model": "Smile", - "name": "Smile", - "vendor": "Plugwise B.V.", + "model": "Gateway", + "name": "Smile Anna", + "vendor": "Plugwise", "binary_sensors": { "plugwise_notification": false }, @@ -61,15 +62,17 @@ "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "model": "Anna", + "model": "ThermoTouch", "name": "Anna", "vendor": "Plugwise", "thermostat": { - "setpoint": 24.0, + "setpoint_low": 20.5, + "setpoint_high": 24.0, "lower_bound": 4.0, "upper_bound": 30.0, "resolution": 0.1 }, + "available": true, "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], "active_preset": "home", "available_schedules": ["standaard"], @@ -78,10 +81,11 @@ "mode": "auto", "sensors": { "temperature": 26.3, - "setpoint": 24.0, "illuminance": 86.0, "cooling_activation_outdoor_temperature": 21.0, - "cooling_deactivation_threshold": 4.0 + "cooling_deactivation_threshold": 4.0, + "setpoint_low": 20.5, + "setpoint_high": 24.0 } } } diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 0c1fef1a171d0e0ebc027a97bca064656af4f7fd..cd2747f423b46264aef89e290bde5fa4e8143327 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -1,6 +1,6 @@ [ { - "smile_name": "Smile", + "smile_name": "Smile Anna", "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", "cooling_present": true, @@ -19,7 +19,7 @@ "upper_bound": 100.0, "resolution": 1.0 }, - "elga_cooling_enabled": true, + "available": true, "binary_sensors": { "dhw_state": false, "heating_state": false, @@ -29,12 +29,13 @@ "flame_state": false }, "sensors": { - "water_temperature": 29.1, - "intended_boiler_temperature": 0.0, - "modulation_level": 52, - "return_temperature": 25.1, + "water_temperature": 19.1, + "dhw_temperature": 46.3, + "intended_boiler_temperature": 18.0, + "modulation_level": 0, + "return_temperature": 22.0, "water_pressure": 1.57, - "outdoor_air_temperature": 3.0 + "outdoor_air_temperature": 28.2 }, "switches": { "dhw_cm_switch": false @@ -46,14 +47,14 @@ "hardware": "AME Smile 2.0 board", "location": "a57efe5f145f498c9be62a9b63626fbf", "mac_address": "012345670001", - "model": "Smile", - "name": "Smile", - "vendor": "Plugwise B.V.", + "model": "Gateway", + "name": "Smile Anna", + "vendor": "Plugwise", "binary_sensors": { "plugwise_notification": false }, "sensors": { - "outdoor_temperature": 20.2 + "outdoor_temperature": 28.2 } }, "3cb70739631c4d17a86b8b12e8a5161b": { @@ -61,15 +62,17 @@ "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "model": "Anna", + "model": "ThermoTouch", "name": "Anna", "vendor": "Plugwise", "thermostat": { - "setpoint": 20.5, + "setpoint_low": 20.5, + "setpoint_high": 24.0, "lower_bound": 4.0, "upper_bound": 30.0, "resolution": 0.1 }, + "available": true, "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], "active_preset": "home", "available_schedules": ["standaard"], @@ -77,11 +80,12 @@ "last_used": "standaard", "mode": "auto", "sensors": { - "temperature": 21.3, - "setpoint": 20.5, + "temperature": 23.0, "illuminance": 86.0, - "cooling_activation_outdoor_temperature": 21.0, - "cooling_deactivation_threshold": 4.0 + "cooling_activation_outdoor_temperature": 25.0, + "cooling_deactivation_threshold": 4.0, + "setpoint_low": 20.5, + "setpoint_high": 24.0 } } } diff --git a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json index fbf5aa63a5ff3da1eecf5ba4dfd8ec9c939fd710..c52f33e63234e3557b71ecd700f7dd253ce32f70 100644 --- a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json +++ b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json @@ -1,19 +1,30 @@ [ { - "smile_name": "P1", - "gateway_id": "e950c7d5e1ee407a858e2a8b5016c8b3", + "smile_name": "Smile P1", + "gateway_id": "cd3e822288064775a7c4afcdd70bdda2", "notifications": {} }, { - "e950c7d5e1ee407a858e2a8b5016c8b3": { + "cd3e822288064775a7c4afcdd70bdda2": { "dev_class": "gateway", "firmware": "3.3.9", "hardware": "AME Smile 2.0 board", "location": "cd3e822288064775a7c4afcdd70bdda2", "mac_address": "012345670001", - "model": "P1", + "model": "Gateway", + "name": "Smile P1", + "vendor": "Plugwise", + "binary_sensors": { + "plugwise_notification": false + } + }, + "e950c7d5e1ee407a858e2a8b5016c8b3": { + "dev_class": "smartmeter", + "location": "cd3e822288064775a7c4afcdd70bdda2", + "model": "2M550E-1012", "name": "P1", - "vendor": "Plugwise B.V.", + "vendor": "ISKRAEMECO", + "available": true, "sensors": { "net_electricity_point": -2816, "electricity_consumed_peak_point": 0, diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json index 1ff62e9e619b73964b066f5c6bce0dc4cba8be76..1ce34e376d719ba4b0ef91fa460c5c6ae9d76160 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/all_data.json @@ -10,10 +10,10 @@ "firmware": "3.1.11", "location": "0000aaaa0000aaaa0000aaaa0000aa00", "mac_address": "01:23:45:67:89:AB", - "model": "Stretch", + "model": "Gateway", "name": "Stretch", - "vendor": "Plugwise B.V.", - "zigbee_mac_address": "ABCD012345670101" + "zigbee_mac_address": "ABCD012345670101", + "vendor": "Plugwise" }, "5871317346d045bc9f6b987ef25ee638": { "dev_class": "water_heater_vessel", diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index bcca1a32abbefc0d0c7807210277e936c0f5dbf2..ad5443a678c2e73faae293424ca08148ce6a3c6a 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -75,11 +75,10 @@ async def test_adam_3_climate_entity_attributes( state = hass.states.get("climate.anna") assert state - assert state.state == HVACMode.COOL + assert state.state == HVACMode.HEAT_COOL assert state.attributes["hvac_action"] == "cooling" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT, - HVACMode.COOL, + HVACMode.HEAT_COOL, HVACMode.AUTO, ] @@ -133,7 +132,7 @@ async def test_adam_climate_entity_climate_changes( assert mock_smile_adam.set_temperature.call_count == 1 mock_smile_adam.set_temperature.assert_called_with( - "c50f167537524366a5af7aa3942feb1e", 25.0 + "c50f167537524366a5af7aa3942feb1e", {"setpoint": 25.0} ) with pytest.raises(ValueError): @@ -165,7 +164,7 @@ async def test_adam_climate_entity_climate_changes( assert mock_smile_adam.set_temperature.call_count == 2 mock_smile_adam.set_temperature.assert_called_with( - "82fa13f017d240daa0d0ea1775420f24", 25.0 + "82fa13f017d240daa0d0ea1775420f24", {"setpoint": 25.0} ) await hass.services.async_call( @@ -203,8 +202,7 @@ async def test_anna_climate_entity_attributes( assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "heating" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT, - HVACMode.COOL, + HVACMode.HEAT_COOL, HVACMode.AUTO, ] @@ -213,8 +211,9 @@ async def test_anna_climate_entity_attributes( assert state.attributes["current_temperature"] == 19.3 assert state.attributes["preset_mode"] == "home" - assert state.attributes["supported_features"] == 17 - assert state.attributes["temperature"] == 20.5 + assert state.attributes["supported_features"] == 18 + assert state.attributes["target_temp_high"] == 24.0 + assert state.attributes["target_temp_low"] == 20.5 assert state.attributes["min_temp"] == 4.0 assert state.attributes["max_temp"] == 30.0 assert state.attributes["target_temp_step"] == 0.1 @@ -231,12 +230,12 @@ async def test_anna_2_climate_entity_attributes( assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "cooling" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT, - HVACMode.COOL, + HVACMode.HEAT_COOL, HVACMode.AUTO, ] - assert state.attributes["temperature"] == 24.0 - assert state.attributes["supported_features"] == 17 + assert state.attributes["supported_features"] == 18 + assert state.attributes["target_temp_high"] == 24.0 + assert state.attributes["target_temp_low"] == 20.5 async def test_anna_3_climate_entity_attributes( @@ -250,8 +249,7 @@ async def test_anna_3_climate_entity_attributes( assert state.state == HVACMode.AUTO assert state.attributes["hvac_action"] == "idle" assert state.attributes["hvac_modes"] == [ - HVACMode.HEAT, - HVACMode.COOL, + HVACMode.HEAT_COOL, HVACMode.AUTO, ] @@ -263,14 +261,14 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( "climate", "set_temperature", - {"entity_id": "climate.anna", "temperature": 25}, + {"entity_id": "climate.anna", "target_temp_high": 25, "target_temp_low": 20}, blocking=True, ) assert mock_smile_anna.set_temperature.call_count == 1 mock_smile_anna.set_temperature.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", - 25.0, + {"setpoint_high": 25.0, "setpoint_low": 20.0}, ) await hass.services.async_call( @@ -288,7 +286,7 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.anna", "hvac_mode": "heat"}, + {"entity_id": "climate.anna", "hvac_mode": "heat_cool"}, blocking=True, ) diff --git a/tests/components/plugwise/test_diagnostics.py b/tests/components/plugwise/test_diagnostics.py index 3e3b2259e15e3bb87b0c2216956ffe5aeafaf76e..7e8d574d5bd7a153e57b4f57aef761ca89b6d65f 100644 --- a/tests/components/plugwise/test_diagnostics.py +++ b/tests/components/plugwise/test_diagnostics.py @@ -46,7 +46,8 @@ async def test_diagnostics( "upper_bound": 99.9, "resolution": 0.01, }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": True, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "away", "available_schedules": [ "CV Roan", @@ -69,6 +70,7 @@ async def test_diagnostics( "name": "Floor kraan", "zigbee_mac_address": "ABCD012345670A02", "vendor": "Plugwise", + "available": True, "sensors": { "temperature": 26.0, "setpoint": 21.5, @@ -85,6 +87,7 @@ async def test_diagnostics( "name": "Bios Cv Thermostatic Radiator ", "zigbee_mac_address": "ABCD012345670A09", "vendor": "Plugwise", + "available": True, "sensors": { "temperature": 17.2, "setpoint": 13.0, @@ -108,7 +111,8 @@ async def test_diagnostics( "upper_bound": 99.9, "resolution": 0.01, }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": True, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "home", "available_schedules": [ "CV Roan", @@ -128,12 +132,11 @@ async def test_diagnostics( "hardware": "AME Smile 2.0 board", "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", "mac_address": "012345670001", - "model": "Adam", + "model": "Gateway", "name": "Adam", "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise B.V.", + "vendor": "Plugwise", "regulation_mode": "heating", - "regulation_modes": [], "binary_sensors": {"plugwise_notification": True}, "sensors": {"outdoor_temperature": 7.81}, }, @@ -146,6 +149,7 @@ async def test_diagnostics( "name": "Thermostatic Radiator Jessie", "zigbee_mac_address": "ABCD012345670A10", "vendor": "Plugwise", + "available": True, "sensors": { "temperature": 17.1, "setpoint": 15.0, @@ -162,6 +166,7 @@ async def test_diagnostics( "name": "Playstation Smart Plug", "zigbee_mac_address": "ABCD012345670A12", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 82.6, "electricity_consumed_interval": 8.6, @@ -178,6 +183,7 @@ async def test_diagnostics( "name": "CV Pomp", "zigbee_mac_address": "ABCD012345670A05", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 35.6, "electricity_consumed_interval": 7.37, @@ -206,6 +212,7 @@ async def test_diagnostics( "name": "NAS", "zigbee_mac_address": "ABCD012345670A14", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 16.5, "electricity_consumed_interval": 0.5, @@ -222,6 +229,7 @@ async def test_diagnostics( "name": "USG Smart Plug", "zigbee_mac_address": "ABCD012345670A16", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 8.5, "electricity_consumed_interval": 0.0, @@ -238,6 +246,7 @@ async def test_diagnostics( "name": "NVR", "zigbee_mac_address": "ABCD012345670A15", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 34.0, "electricity_consumed_interval": 9.15, @@ -254,6 +263,7 @@ async def test_diagnostics( "name": "Fibaro HC2", "zigbee_mac_address": "ABCD012345670A13", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 12.5, "electricity_consumed_interval": 3.8, @@ -277,7 +287,8 @@ async def test_diagnostics( "upper_bound": 99.9, "resolution": 0.01, }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": True, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "asleep", "available_schedules": [ "CV Roan", @@ -300,6 +311,7 @@ async def test_diagnostics( "name": "Thermostatic Radiator Badkamer", "zigbee_mac_address": "ABCD012345670A17", "vendor": "Plugwise", + "available": True, "sensors": { "temperature": 19.1, "setpoint": 14.0, @@ -323,7 +335,8 @@ async def test_diagnostics( "upper_bound": 99.9, "resolution": 0.01, }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": True, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "away", "available_schedules": [ "CV Roan", @@ -345,6 +358,7 @@ async def test_diagnostics( "name": "Ziggo Modem", "zigbee_mac_address": "ABCD012345670A01", "vendor": "Plugwise", + "available": True, "sensors": { "electricity_consumed": 12.2, "electricity_consumed_interval": 2.97, @@ -368,7 +382,8 @@ async def test_diagnostics( "upper_bound": 100.0, "resolution": 0.01, }, - "preset_modes": ["home", "asleep", "away", "no_frost"], + "available": True, + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "active_preset": "no_frost", "available_schedules": [ "CV Roan", diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index 2b4baccc7c9cce0322aca63cb55ed7f4737fc6ca..9039c5a476e2fe1d35c10711f8a53f1094868ebc 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -47,6 +47,10 @@ async def test_anna_as_smt_climate_sensor_entities( assert state assert float(state.state) == 29.1 + state = hass.states.get("sensor.opentherm_dhw_temperature") + assert state + assert float(state.state) == 46.3 + state = hass.states.get("sensor.anna_illuminance") assert state assert float(state.state) == 86.0 diff --git a/tests/components/pushover/test_config_flow.py b/tests/components/pushover/test_config_flow.py index 1e919167c6a1d42e107be4e7b3b5aabbc6c73eac..642f1b1b1bbae47be41e9585b1b603abc8253700 100644 --- a/tests/components/pushover/test_config_flow.py +++ b/tests/components/pushover/test_config_flow.py @@ -140,19 +140,6 @@ async def test_flow_conn_err(hass: HomeAssistant, mock_pushover: MagicMock) -> N assert result["errors"] == {"base": "cannot_connect"} -async def test_import(hass: HomeAssistant) -> None: - """Test user initialized flow with unreachable server.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Pushover" - assert result["data"] == MOCK_CONFIG - - async def test_reauth_success(hass: HomeAssistant) -> None: """Test we can reauth.""" entry = MockConfigEntry( diff --git a/tests/components/pushover/test_init.py b/tests/components/pushover/test_init.py index 7a8b02c93a0c6ce9225d06e383d48b449b209b09..635aec520b528836892d716d1e0c566b59779f03 100644 --- a/tests/components/pushover/test_init.py +++ b/tests/components/pushover/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import aiohttp from pushover_complete import BadAPIRequestError import pytest +from requests_mock import Mocker from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.components.pushover.const import DOMAIN @@ -19,7 +20,7 @@ from tests.common import MockConfigEntry from tests.components.repairs import get_repairs -@pytest.fixture(autouse=True) +@pytest.fixture(autouse=False) def mock_pushover(): """Mock pushover.""" with patch( @@ -33,6 +34,7 @@ async def test_setup( hass_ws_client: Callable[ [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] ], + mock_pushover: MagicMock, ) -> None: """Test integration failed due to an error.""" assert await async_setup_component( @@ -50,13 +52,15 @@ async def test_setup( }, ) await hass.async_block_till_done() - assert hass.config_entries.async_entries(DOMAIN) + assert not hass.config_entries.async_entries(DOMAIN) issues = await get_repairs(hass, hass_ws_client) assert len(issues) == 1 - assert issues[0]["issue_id"] == "deprecated_yaml" + assert issues[0]["issue_id"] == "removed_yaml" -async def test_async_setup_entry_success(hass: HomeAssistant) -> None: +async def test_async_setup_entry_success( + hass: HomeAssistant, mock_pushover: MagicMock +) -> None: """Test pushover successful setup.""" entry = MockConfigEntry( domain=DOMAIN, @@ -68,7 +72,7 @@ async def test_async_setup_entry_success(hass: HomeAssistant) -> None: assert entry.state == ConfigEntryState.LOADED -async def test_unique_id_updated(hass: HomeAssistant) -> None: +async def test_unique_id_updated(hass: HomeAssistant, mock_pushover: MagicMock) -> None: """Test updating unique_id to new format.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, unique_id="MYUSERKEY") entry.add_to_hass(hass) @@ -106,3 +110,20 @@ async def test_async_setup_entry_failed_conn_error( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_async_setup_entry_failed_json_error( + hass: HomeAssistant, requests_mock: Mocker +) -> None: + """Test pushover failed setup due to bad json response from library.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + requests_mock.post( + "https://api.pushover.net/1/users/validate.json", status_code=204 + ) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/radarr/fixtures/movie.json b/tests/components/radarr/fixtures/movie.json index 0f974859631849a0be5458e95c83aaff943cc599..b33ff6fc481984f8f5f40c5d201856f42a6f5d74 100644 --- a/tests/components/radarr/fixtures/movie.json +++ b/tests/components/radarr/fixtures/movie.json @@ -21,8 +21,8 @@ "sortTitle": "string", "sizeOnDisk": 0, "overview": "string", - "inCinemas": "string", - "physicalRelease": "string", + "inCinemas": "2020-11-06T00:00:00Z", + "physicalRelease": "2019-03-19T00:00:00Z", "images": [ { "coverType": "poster", @@ -50,7 +50,7 @@ "certification": "string", "genres": ["string"], "tags": [0], - "added": "string", + "added": "2018-12-28T05:56:49Z", "ratings": { "votes": 0, "value": 0 diff --git a/tests/components/rainmachine/conftest.py b/tests/components/rainmachine/conftest.py index af00a1013e02e75260217501463af0d8a8c0b27a..685f307d19748804ddeccaf04742a28590049f82 100644 --- a/tests/components/rainmachine/conftest.py +++ b/tests/components/rainmachine/conftest.py @@ -76,19 +76,19 @@ def controller_mac_fixture(): return "aa:bb:cc:dd:ee:ff" -@pytest.fixture(name="data_api_versions", scope="session") +@pytest.fixture(name="data_api_versions", scope="package") def data_api_versions_fixture(): """Define API version data.""" return json.loads(load_fixture("api_versions_data.json", "rainmachine")) -@pytest.fixture(name="data_diagnostics_current", scope="session") +@pytest.fixture(name="data_diagnostics_current", scope="package") def data_diagnostics_current_fixture(): """Define current diagnostics data.""" return json.loads(load_fixture("diagnostics_current_data.json", "rainmachine")) -@pytest.fixture(name="data_machine_firmare_update_status", scope="session") +@pytest.fixture(name="data_machine_firmare_update_status", scope="package") def data_machine_firmare_update_status_fixture(): """Define machine firmware update status data.""" return json.loads( @@ -96,31 +96,31 @@ def data_machine_firmare_update_status_fixture(): ) -@pytest.fixture(name="data_programs", scope="session") +@pytest.fixture(name="data_programs", scope="package") def data_programs_fixture(): """Define program data.""" return json.loads(load_fixture("programs_data.json", "rainmachine")) -@pytest.fixture(name="data_provision_settings", scope="session") +@pytest.fixture(name="data_provision_settings", scope="package") def data_provision_settings_fixture(): """Define provisioning settings data.""" return json.loads(load_fixture("provision_settings_data.json", "rainmachine")) -@pytest.fixture(name="data_restrictions_current", scope="session") +@pytest.fixture(name="data_restrictions_current", scope="package") def data_restrictions_current_fixture(): """Define current restrictions settings data.""" return json.loads(load_fixture("restrictions_current_data.json", "rainmachine")) -@pytest.fixture(name="data_restrictions_universal", scope="session") +@pytest.fixture(name="data_restrictions_universal", scope="package") def data_restrictions_universal_fixture(): """Define universal restrictions settings data.""" return json.loads(load_fixture("restrictions_universal_data.json", "rainmachine")) -@pytest.fixture(name="data_zones", scope="session") +@pytest.fixture(name="data_zones", scope="package") def data_zones_fixture(): """Define zone data.""" return json.loads(load_fixture("zones_data.json", "rainmachine")) diff --git a/tests/components/rainmachine/test_diagnostics.py b/tests/components/rainmachine/test_diagnostics.py index 7cf8406d2ae132fb5536e8d7d87bd3e4fff81794..a3c03c956a4add88376b2aa7082a5e44c5b7c38b 100644 --- a/tests/components/rainmachine/test_diagnostics.py +++ b/tests/components/rainmachine/test_diagnostics.py @@ -10,6 +10,9 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_rainmach """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "rainmachine", "title": "Mock Title", "data": { "ip_address": "192.168.1.100", @@ -18,14 +21,15 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_rainmach "ssl": True, }, "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { "coordinator": { - "api.versions": { - "apiVer": "4.6.1", - "hwVer": "3", - "swVer": "4.0.1144", - }, + "api.versions": {"apiVer": "4.6.1", "hwVer": "3", "swVer": "4.0.1144"}, "machine.firmware_update_status": { "lastUpdateCheckTimestamp": 1657825288, "packageDetails": [], @@ -628,6 +632,9 @@ async def test_entry_diagnostics_failed_controller_diagnostics( controller.diagnostics.current.side_effect = RainMachineError assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "rainmachine", "title": "Mock Title", "data": { "ip_address": "192.168.1.100", @@ -636,14 +643,15 @@ async def test_entry_diagnostics_failed_controller_diagnostics( "ssl": True, }, "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { "coordinator": { - "api.versions": { - "apiVer": "4.6.1", - "hwVer": "3", - "swVer": "4.0.1144", - }, + "api.versions": {"apiVer": "4.6.1", "hwVer": "3", "swVer": "4.0.1144"}, "machine.firmware_update_status": { "lastUpdateCheckTimestamp": 1657825288, "packageDetails": [], diff --git a/tests/components/recollect_waste/conftest.py b/tests/components/recollect_waste/conftest.py index a0d002e9d9a4357a45c173a459d8778667a7c98b..9373a9aa9694a994f313cbe73d3309f3412e3ed3 100644 --- a/tests/components/recollect_waste/conftest.py +++ b/tests/components/recollect_waste/conftest.py @@ -16,9 +16,13 @@ from tests.common import MockConfigEntry @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, unique_id): +def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=f"{config[CONF_PLACE_ID]}, {config[CONF_SERVICE_ID]}", + data=config, + ) entry.add_to_hass(hass) return entry @@ -51,9 +55,3 @@ async def setup_recollect_waste_fixture(hass, config): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "12345, 12345" diff --git a/tests/components/recollect_waste/test_diagnostics.py b/tests/components/recollect_waste/test_diagnostics.py index c9c9ba5a93f5b7f3cfcba975fd1a036904954a74..93978135681e97fe8590f43871e9c4f207929283 100644 --- a/tests/components/recollect_waste/test_diagnostics.py +++ b/tests/components/recollect_waste/test_diagnostics.py @@ -1,4 +1,6 @@ """Test ReCollect Waste diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -7,7 +9,19 @@ async def test_entry_diagnostics( ): """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": config_entry.as_dict(), + "entry": { + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "recollect_waste", + "title": REDACTED, + "data": {"place_id": REDACTED, "service_id": "12345"}, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + }, "data": [ { "date": { @@ -17,7 +31,7 @@ async def test_entry_diagnostics( "pickup_types": [ {"name": "garbage", "friendly_name": "Trash Collection"} ], - "area_name": "The Sun", + "area_name": REDACTED, } ], } diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 8d1929c7362a9f7fb4e9b40b2b135169497f2f80..0ddc76e442387f18879d9c31e22e222cf4237c5d 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime import time from typing import Any, cast @@ -21,8 +21,6 @@ from homeassistant.util import dt as dt_util from . import db_schema_0 -from tests.common import async_fire_time_changed, fire_time_changed - DEFAULT_PURGE_TASKS = 3 @@ -69,9 +67,7 @@ def wait_recording_done(hass: HomeAssistant) -> None: def trigger_db_commit(hass: HomeAssistant) -> None: """Force the recorder to commit.""" - for _ in range(recorder.DEFAULT_COMMIT_INTERVAL): - # We only commit on time change - fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + recorder.get_instance(hass)._async_commit(dt_util.utcnow()) async def async_wait_recording_done(hass: HomeAssistant) -> None: @@ -100,8 +96,7 @@ async def async_wait_purge_done(hass: HomeAssistant, max: int = None) -> None: @ha.callback def async_trigger_db_commit(hass: HomeAssistant) -> None: """Force the recorder to commit. Async friendly.""" - for _ in range(recorder.DEFAULT_COMMIT_INTERVAL): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + recorder.get_instance(hass)._async_commit(dt_util.utcnow()) async def async_recorder_block_till_done(hass: HomeAssistant) -> None: diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index e829c2aa13b1bf8aa603c8a7de4c2b7656567324..511520faa711cf6cfe99d91a3ed6dc7269ee5b5b 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -async def test_async_pre_backup(hass: HomeAssistant, recorder_mock) -> None: +async def test_async_pre_backup(recorder_mock, hass: HomeAssistant) -> None: """Test pre backup.""" with patch( "homeassistant.components.recorder.core.Recorder.lock_database" @@ -20,7 +20,7 @@ async def test_async_pre_backup(hass: HomeAssistant, recorder_mock) -> None: async def test_async_pre_backup_with_timeout( - hass: HomeAssistant, recorder_mock + recorder_mock, hass: HomeAssistant ) -> None: """Test pre backup with timeout.""" with patch( @@ -32,7 +32,7 @@ async def test_async_pre_backup_with_timeout( async def test_async_pre_backup_with_migration( - hass: HomeAssistant, recorder_mock + recorder_mock, hass: HomeAssistant ) -> None: """Test pre backup with migration.""" with patch( @@ -42,7 +42,7 @@ async def test_async_pre_backup_with_migration( await async_pre_backup(hass) -async def test_async_post_backup(hass: HomeAssistant, recorder_mock) -> None: +async def test_async_post_backup(recorder_mock, hass: HomeAssistant) -> None: """Test post backup.""" with patch( "homeassistant.components.recorder.core.Recorder.unlock_database" @@ -51,7 +51,7 @@ async def test_async_post_backup(hass: HomeAssistant, recorder_mock) -> None: assert unlock_mock.called -async def test_async_post_backup_failure(hass: HomeAssistant, recorder_mock) -> None: +async def test_async_post_backup_failure(recorder_mock, hass: HomeAssistant) -> None: """Test post backup failure.""" with patch( "homeassistant.components.recorder.core.Recorder.unlock_database", diff --git a/tests/components/recorder/test_filters_with_entityfilter.py b/tests/components/recorder/test_filters_with_entityfilter.py index 62bb1b3fa8d72bb1578b789861d203c45d06583f..89a271dac02e26e813ae5586fff0037d0f1e39c8 100644 --- a/tests/components/recorder/test_filters_with_entityfilter.py +++ b/tests/components/recorder/test_filters_with_entityfilter.py @@ -71,7 +71,7 @@ async def _async_get_states_and_events_with_filter( return filtered_states_entity_ids, filtered_events_entity_ids -async def test_included_and_excluded_simple_case_no_domains(hass, recorder_mock): +async def test_included_and_excluded_simple_case_no_domains(recorder_mock, hass): """Test filters with included and excluded without domains.""" filter_accept = {"sensor.kitchen4", "switch.kitchen"} filter_reject = { @@ -127,7 +127,7 @@ async def test_included_and_excluded_simple_case_no_domains(hass, recorder_mock) assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_included_and_excluded_simple_case_no_globs(hass, recorder_mock): +async def test_included_and_excluded_simple_case_no_globs(recorder_mock, hass): """Test filters with included and excluded without globs.""" filter_accept = {"switch.bla", "sensor.blu", "sensor.keep"} filter_reject = {"sensor.bli"} @@ -168,7 +168,7 @@ async def test_included_and_excluded_simple_case_no_globs(hass, recorder_mock): async def test_included_and_excluded_simple_case_without_underscores( - hass, recorder_mock + recorder_mock, hass ): """Test filters with included and excluded without underscores.""" filter_accept = {"light.any", "sensor.kitchen4", "switch.kitchen"} @@ -221,7 +221,7 @@ async def test_included_and_excluded_simple_case_without_underscores( assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_included_and_excluded_simple_case_with_underscores(hass, recorder_mock): +async def test_included_and_excluded_simple_case_with_underscores(recorder_mock, hass): """Test filters with included and excluded with underscores.""" filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} filter_reject = {"switch.other", "cover.any", "sensor.weather_5", "light.kitchen"} @@ -273,7 +273,7 @@ async def test_included_and_excluded_simple_case_with_underscores(hass, recorder assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_included_and_excluded_complex_case(hass, recorder_mock): +async def test_included_and_excluded_complex_case(recorder_mock, hass): """Test filters with included and excluded with a complex filter.""" filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} filter_reject = { @@ -330,7 +330,7 @@ async def test_included_and_excluded_complex_case(hass, recorder_mock): assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_included_entities_and_excluded_domain(hass, recorder_mock): +async def test_included_entities_and_excluded_domain(recorder_mock, hass): """Test filters with included entities and excluded domain.""" filter_accept = { "media_player.test", @@ -376,7 +376,7 @@ async def test_included_entities_and_excluded_domain(hass, recorder_mock): assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_same_domain_included_excluded(hass, recorder_mock): +async def test_same_domain_included_excluded(recorder_mock, hass): """Test filters with the same domain included and excluded.""" filter_accept = { "media_player.test", @@ -422,7 +422,7 @@ async def test_same_domain_included_excluded(hass, recorder_mock): assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_same_entity_included_excluded(hass, recorder_mock): +async def test_same_entity_included_excluded(recorder_mock, hass): """Test filters with the same entity included and excluded.""" filter_accept = { "media_player.test", @@ -468,7 +468,7 @@ async def test_same_entity_included_excluded(hass, recorder_mock): assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_same_entity_included_excluded_include_domain_wins(hass, recorder_mock): +async def test_same_entity_included_excluded_include_domain_wins(recorder_mock, hass): """Test filters with domain and entities and the include domain wins.""" filter_accept = { "media_player.test2", @@ -516,7 +516,7 @@ async def test_same_entity_included_excluded_include_domain_wins(hass, recorder_ assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_specificly_included_entity_always_wins(hass, recorder_mock): +async def test_specificly_included_entity_always_wins(recorder_mock, hass): """Test specificlly included entity always wins.""" filter_accept = { "media_player.test2", @@ -564,7 +564,7 @@ async def test_specificly_included_entity_always_wins(hass, recorder_mock): assert not filtered_events_entity_ids.intersection(filter_reject) -async def test_specificly_included_entity_always_wins_over_glob(hass, recorder_mock): +async def test_specificly_included_entity_always_wins_over_glob(recorder_mock, hass): """Test specificlly included entity always wins over a glob.""" filter_accept = { "sensor.apc900va_status", diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index f18ba0768ca7053ed698524188f2fb289bd00a73..6362b83f78a32829231e2cff0dbb034113e576d5 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -254,6 +254,9 @@ def test_state_changes_during_period_descending(hass_recorder): start = dt_util.utcnow() point = start + timedelta(seconds=1) + point2 = start + timedelta(seconds=1, microseconds=2) + point3 = start + timedelta(seconds=1, microseconds=3) + point4 = start + timedelta(seconds=1, microseconds=4) end = point + timedelta(seconds=1) with patch( @@ -265,12 +268,19 @@ def test_state_changes_during_period_descending(hass_recorder): with patch( "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point ): - states = [ - set_state("idle"), - set_state("Netflix"), - set_state("Plex"), - set_state("YouTube"), - ] + states = [set_state("idle")] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 + ): + states.append(set_state("Netflix")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 + ): + states.append(set_state("Plex")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 + ): + states.append(set_state("YouTube")) with patch( "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end @@ -650,10 +660,15 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: async def test_state_changes_during_period_query_during_migration_to_schema_25( - hass: ha.HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: ha.HomeAssistant, + recorder_db_url: str, ): """Test we can query data prior to schema 25 and during migration to schema 25.""" + if recorder_db_url.startswith("mysql://"): + # This test doesn't run on MySQL / MariaDB; we can't drop table state_attributes + return + instance = await async_setup_recorder_instance(hass, {}) start = dt_util.utcnow() @@ -700,10 +715,15 @@ async def test_state_changes_during_period_query_during_migration_to_schema_25( async def test_get_states_query_during_migration_to_schema_25( - hass: ha.HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: ha.HomeAssistant, + recorder_db_url: str, ): """Test we can query data prior to schema 25 and during migration to schema 25.""" + if recorder_db_url.startswith("mysql://"): + # This test doesn't run on MySQL / MariaDB; we can't drop table state_attributes + return + instance = await async_setup_recorder_instance(hass, {}) start = dt_util.utcnow() @@ -746,10 +766,15 @@ async def test_get_states_query_during_migration_to_schema_25( async def test_get_states_query_during_migration_to_schema_25_multiple_entities( - hass: ha.HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: ha.HomeAssistant, + recorder_db_url: str, ): """Test we can query data prior to schema 25 and during migration to schema 25.""" + if recorder_db_url.startswith("mysql://"): + # This test doesn't run on MySQL / MariaDB; we can't drop table state_attributes + return + instance = await async_setup_recorder_instance(hass, {}) start = dt_util.utcnow() @@ -795,8 +820,8 @@ async def test_get_states_query_during_migration_to_schema_25_multiple_entities( async def test_get_full_significant_states_handles_empty_last_changed( - hass: ha.HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: ha.HomeAssistant, ): """Test getting states when last_changed is null.""" await async_setup_recorder_instance(hass, {}) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 05ae1f1a372c7cd6fffc0088a763d67cf441debf..ca4cbc9a4f94f2a78925a4a52484030d594f7f79 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -17,12 +17,15 @@ from homeassistant.components.recorder import ( CONF_AUTO_PURGE, CONF_AUTO_REPACK, CONF_COMMIT_INTERVAL, + CONF_DB_MAX_RETRIES, + CONF_DB_RETRY_WAIT, CONF_DB_URL, CONFIG_SCHEMA, DOMAIN, SQLITE_URL_PREFIX, Recorder, get_instance, + pool, ) from homeassistant.components.recorder.const import KEEPALIVE_TIME from homeassistant.components.recorder.db_schema import ( @@ -89,7 +92,7 @@ def _default_recorder(hass): async def test_shutdown_before_startup_finishes( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, tmp_path + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant, tmp_path ): """Test shutdown before recorder starts is clean.""" @@ -121,8 +124,8 @@ async def test_shutdown_before_startup_finishes( async def test_canceled_before_startup_finishes( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ): """Test recorder shuts down when its startup future is canceled out from under it.""" @@ -142,7 +145,7 @@ async def test_canceled_before_startup_finishes( ) -async def test_shutdown_closes_connections(hass, recorder_mock): +async def test_shutdown_closes_connections(recorder_mock, hass): """Test shutdown closes connections.""" hass.state = CoreState.not_running @@ -168,7 +171,7 @@ async def test_shutdown_closes_connections(hass, recorder_mock): async def test_state_gets_saved_when_set_before_start_event( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test we can record an event when starting with not running.""" @@ -194,7 +197,7 @@ async def test_state_gets_saved_when_set_before_start_event( assert db_states[0].event_id is None -async def test_saving_state(hass: HomeAssistant, recorder_mock): +async def test_saving_state(recorder_mock, hass: HomeAssistant): """Test saving and restoring a state.""" entity_id = "test.recorder" state = "restoring_from_db" @@ -217,7 +220,7 @@ async def test_saving_state(hass: HomeAssistant, recorder_mock): async def test_saving_many_states( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test we expire after many commits.""" instance = await async_setup_recorder_instance( @@ -245,7 +248,7 @@ async def test_saving_many_states( async def test_saving_state_with_intermixed_time_changes( - hass: HomeAssistant, recorder_mock + recorder_mock, hass: HomeAssistant ): """Test saving states with intermixed time changes.""" entity_id = "test.recorder" @@ -345,7 +348,7 @@ def test_saving_state_with_sqlalchemy_exception(hass, hass_recorder, caplog): async def test_force_shutdown_with_queue_of_writes_that_generate_exceptions( - hass, async_setup_recorder_instance, caplog + async_setup_recorder_instance, hass, caplog ): """Test forcing shutdown.""" instance = await async_setup_recorder_instance(hass) @@ -662,6 +665,23 @@ def test_recorder_setup_failure(hass): hass.stop() +def test_recorder_validate_schema_failure(hass): + """Test some exceptions.""" + recorder_helper.async_initialize_recorder(hass) + with patch( + "homeassistant.components.recorder.migration._get_schema_version" + ) as inspect_schema_version, patch( + "homeassistant.components.recorder.core.time.sleep" + ): + inspect_schema_version.side_effect = ImportError("driver not found") + rec = _default_recorder(hass) + rec.async_initialize() + rec.start() + rec.join() + + hass.stop() + + def test_recorder_setup_failure_without_event_listener(hass): """Test recorder setup failure when the event listener is not setup.""" recorder_helper.async_initialize_recorder(hass) @@ -982,54 +1002,48 @@ def test_statistics_runs_initiated(hass_recorder): ) - timedelta(minutes=5) -def test_compile_missing_statistics(tmpdir): +@pytest.mark.freeze_time("2022-09-13 09:00:00+02:00") +def test_compile_missing_statistics(tmpdir, freezer): """Test missing statistics are compiled on startup.""" now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=now - ): - - hass = get_test_home_assistant() - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) - - with session_scope(hass=hass) as session: - statistics_runs = list(session.query(StatisticsRuns)) - assert len(statistics_runs) == 1 - last_run = process_timestamp(statistics_runs[0].start) - assert last_run == now - timedelta(minutes=5) + hass = get_test_home_assistant() + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) - wait_recording_done(hass) - wait_recording_done(hass) - hass.stop() + with session_scope(hass=hass) as session: + statistics_runs = list(session.query(StatisticsRuns)) + assert len(statistics_runs) == 1 + last_run = process_timestamp(statistics_runs[0].start) + assert last_run == now - timedelta(minutes=5) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=now + timedelta(hours=1), - ): + wait_recording_done(hass) + wait_recording_done(hass) + hass.stop() - hass = get_test_home_assistant() - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) + # Start Home Assistant one hour later + freezer.tick(timedelta(hours=1)) + hass = get_test_home_assistant() + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) - with session_scope(hass=hass) as session: - statistics_runs = list(session.query(StatisticsRuns)) - assert len(statistics_runs) == 13 # 12 5-minute runs - last_run = process_timestamp(statistics_runs[1].start) - assert last_run == now + with session_scope(hass=hass) as session: + statistics_runs = list(session.query(StatisticsRuns)) + assert len(statistics_runs) == 13 # 12 5-minute runs + last_run = process_timestamp(statistics_runs[1].start) + assert last_run == now - wait_recording_done(hass) - wait_recording_done(hass) - hass.stop() + wait_recording_done(hass) + wait_recording_done(hass) + hass.stop() def test_saving_sets_old_state(hass_recorder): @@ -1356,8 +1370,8 @@ def test_entity_id_filter(hass_recorder): async def test_database_lock_and_unlock( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, tmp_path, ): """Test writing events during lock getting written after unlocking.""" @@ -1398,8 +1412,8 @@ async def test_database_lock_and_unlock( async def test_database_lock_and_overflow( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, tmp_path, ): """Test writing events during lock leading to overflow the queue causes the database to unlock.""" @@ -1436,8 +1450,12 @@ async def test_database_lock_and_overflow( assert not instance.unlock_database() -async def test_database_lock_timeout(hass, recorder_mock): +async def test_database_lock_timeout(recorder_mock, hass, recorder_db_url): """Test locking database timeout when recorder stopped.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite: Locking is not implemented for other engines + return + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) instance = get_instance(hass) @@ -1459,7 +1477,7 @@ async def test_database_lock_timeout(hass, recorder_mock): block_task.event.set() -async def test_database_lock_without_instance(hass, recorder_mock): +async def test_database_lock_without_instance(recorder_mock, hass): """Test database lock doesn't fail if instance is not initialized.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) @@ -1480,8 +1498,8 @@ async def test_in_memory_database(hass, caplog): async def test_database_connection_keep_alive( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ): """Test we keep alive socket based dialects.""" @@ -1500,11 +1518,16 @@ async def test_database_connection_keep_alive( async def test_database_connection_keep_alive_disabled_on_sqlite( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + recorder_db_url: str, ): """Test we do not do keep alive for sqlite.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite, keepalive runs on other engines + return + instance = await async_setup_recorder_instance(hass) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await instance.async_recorder_ready.wait() @@ -1576,7 +1599,7 @@ def test_deduplication_state_attributes_inside_commit_interval(hass_recorder, ca assert first_attributes_id == last_attributes_id -async def test_async_block_till_done(hass, async_setup_recorder_instance): +async def test_async_block_till_done(async_setup_recorder_instance, hass): """Test we can block until recordering is done.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) @@ -1596,3 +1619,162 @@ async def test_async_block_till_done(hass, async_setup_recorder_instance): states = await instance.async_add_executor_job(_fetch_states) assert len(states) == 2 await hass.async_block_till_done() + + +@pytest.mark.parametrize( + "db_url, echo", + ( + ("sqlite://blabla", None), + ("mariadb://blabla", False), + ("mysql://blabla", False), + ("mariadb+pymysql://blabla", False), + ("mysql+pymysql://blabla", False), + ("postgresql://blabla", False), + ), +) +async def test_disable_echo(hass, db_url, echo, caplog): + """Test echo is disabled for non sqlite databases.""" + recorder_helper.async_initialize_recorder(hass) + + class MockEvent: + def listen(self, _, _2, callback): + callback(None, None) + + mock_event = MockEvent() + with patch( + "homeassistant.components.recorder.core.create_engine" + ) as create_engine_mock, patch( + "homeassistant.components.recorder.core.sqlalchemy_event", mock_event + ): + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: db_url}}) + create_engine_mock.assert_called_once() + assert create_engine_mock.mock_calls[0][2].get("echo") == echo + + +@pytest.mark.parametrize( + "config_url, expected_connect_args", + ( + ( + "mariadb://user:password@SERVER_IP/DB_NAME", + {"charset": "utf8mb4"}, + ), + ( + "mariadb+pymysql://user:password@SERVER_IP/DB_NAME", + {"charset": "utf8mb4"}, + ), + ( + "mysql://user:password@SERVER_IP/DB_NAME", + {"charset": "utf8mb4"}, + ), + ( + "mysql+pymysql://user:password@SERVER_IP/DB_NAME", + {"charset": "utf8mb4"}, + ), + ( + "mysql://user:password@SERVER_IP/DB_NAME?charset=utf8mb4", + {"charset": "utf8mb4"}, + ), + ( + "mysql://user:password@SERVER_IP/DB_NAME?blah=bleh&charset=other", + {"charset": "utf8mb4"}, + ), + ( + "postgresql://blabla", + {}, + ), + ( + "sqlite://blabla", + {}, + ), + ), +) +async def test_mysql_missing_utf8mb4(hass, config_url, expected_connect_args): + """Test recorder fails to setup if charset=utf8mb4 is missing from db_url.""" + recorder_helper.async_initialize_recorder(hass) + + class MockEvent: + def listen(self, _, _2, callback): + callback(None, None) + + mock_event = MockEvent() + with patch( + "homeassistant.components.recorder.core.create_engine" + ) as create_engine_mock, patch( + "homeassistant.components.recorder.core.sqlalchemy_event", mock_event + ): + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: config_url}}) + create_engine_mock.assert_called_once() + + connect_args = create_engine_mock.mock_calls[0][2].get("connect_args", {}) + for key, value in expected_connect_args.items(): + assert connect_args[key] == value + + +@pytest.mark.parametrize( + "config_url", + ( + "mysql://user:password@SERVER_IP/DB_NAME", + "mysql://user:password@SERVER_IP/DB_NAME?charset=utf8mb4", + "mysql://user:password@SERVER_IP/DB_NAME?blah=bleh&charset=other", + ), +) +async def test_connect_args_priority(hass, config_url): + """Test connect_args has priority over URL query.""" + connect_params = [] + recorder_helper.async_initialize_recorder(hass) + + class MockDialect: + """Non functioning dialect, good enough that SQLAlchemy tries connecting.""" + + __bases__ = [] + _has_events = False + + def __init__(*args, **kwargs): + ... + + def connect(self, *args, **params): + nonlocal connect_params + connect_params.append(params) + return True + + def create_connect_args(self, url): + return ([], {"charset": "invalid"}) + + @classmethod + def dbapi(cls): + ... + + def engine_created(*args): + ... + + def get_dialect_pool_class(self, *args): + return pool.RecorderPool + + def initialize(*args): + ... + + def on_connect_url(self, url): + return False + + class MockEntrypoint: + def engine_created(*_): + ... + + def get_dialect_cls(*_): + return MockDialect + + with patch("sqlalchemy.engine.url.URL._get_entrypoint", MockEntrypoint), patch( + "sqlalchemy.engine.create.util.get_cls_kwargs", return_value=["echo"] + ): + await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_DB_URL: config_url, + CONF_DB_MAX_RETRIES: 1, + CONF_DB_RETRY_WAIT: 0, + } + }, + ) + assert connect_params[0]["charset"] == "utf8mb4" diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 9e0609de5b6272d5c76f858ed54186f9b2d2b758..45268ae819b061a196c35393eec54458c271a9dd 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -134,14 +134,16 @@ async def test_database_migration_encounters_corruption(hass): sqlite3_exception.__cause__ = sqlite3.DatabaseError() with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), patch( - "homeassistant.components.recorder.migration.schema_is_current", - side_effect=[False, True], + "homeassistant.components.recorder.migration._schema_is_current", + side_effect=[False], ), patch( "homeassistant.components.recorder.migration.migrate_schema", side_effect=sqlite3_exception, ), patch( "homeassistant.components.recorder.core.move_away_broken_database" - ) as move_away: + ) as move_away, patch( + "homeassistant.components.recorder.Recorder._schedule_compile_missing_statistics", + ): recorder_helper.async_initialize_recorder(hass) await async_setup_component( hass, "recorder", {"recorder": {"db_url": "sqlite://"}} @@ -159,8 +161,8 @@ async def test_database_migration_encounters_corruption_not_sqlite(hass): assert recorder.util.async_migration_in_progress(hass) is False with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), patch( - "homeassistant.components.recorder.migration.schema_is_current", - side_effect=[False, True], + "homeassistant.components.recorder.migration._schema_is_current", + side_effect=[False], ), patch( "homeassistant.components.recorder.migration.migrate_schema", side_effect=DatabaseError("statement", {}, []), diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index c6c447c01c9422b099dbc9096eae8b2f051f5574..f135ae8af435ae081b761eddf428c9cbab1d3e1c 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -53,7 +53,7 @@ def mock_use_sqlite(request): async def test_purge_old_states( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test deleting old states.""" instance = await async_setup_recorder_instance(hass) @@ -135,9 +135,16 @@ async def test_purge_old_states( async def test_purge_old_states_encouters_database_corruption( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, + recorder_db_url: str, ): """Test database image image is malformed while deleting old states.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite, wiping the database on error only happens + # with SQLite. + return + await async_setup_recorder_instance(hass) await _add_test_states(hass) @@ -165,8 +172,8 @@ async def test_purge_old_states_encouters_database_corruption( async def test_purge_old_states_encounters_temporary_mysql_error( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, caplog, ): """Test retry on specific mysql operational errors.""" @@ -196,8 +203,8 @@ async def test_purge_old_states_encounters_temporary_mysql_error( async def test_purge_old_states_encounters_operational_error( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, caplog, ): """Test error on operational errors that are not mysql does not retry.""" @@ -222,7 +229,7 @@ async def test_purge_old_states_encounters_operational_error( async def test_purge_old_events( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test deleting old events.""" instance = await async_setup_recorder_instance(hass) @@ -259,7 +266,7 @@ async def test_purge_old_events( async def test_purge_old_recorder_runs( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test deleting old recorder runs keeps current run.""" instance = await async_setup_recorder_instance(hass) @@ -295,7 +302,7 @@ async def test_purge_old_recorder_runs( async def test_purge_old_statistics_runs( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test deleting old statistics runs keeps the latest run.""" instance = await async_setup_recorder_instance(hass) @@ -320,8 +327,8 @@ async def test_purge_old_statistics_runs( @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_method( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, use_sqlite: bool, ): @@ -364,7 +371,7 @@ async def test_purge_method( assert recorder_runs.count() == 7 runs_before_purge = recorder_runs.all() - statistics_runs = session.query(StatisticsRuns) + statistics_runs = session.query(StatisticsRuns).order_by(StatisticsRuns.run_id) assert statistics_runs.count() == 7 statistic_runs_before_purge = statistics_runs.all() @@ -431,13 +438,16 @@ async def test_purge_method( await hass.services.async_call("recorder", "purge", service_data=service_data) await hass.async_block_till_done() await async_wait_purge_done(hass) - assert "Vacuuming SQL DB to free space" in caplog.text + assert ( + "Vacuuming SQL DB to free space" in caplog.text + or "Optimizing SQL DB to free space" in caplog.text + ) @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_edge_case( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, use_sqlite: bool, ): """Test states and events are purged even if they occurred shortly before purge_before.""" @@ -503,8 +513,8 @@ async def test_purge_edge_case( async def test_purge_cutoff_date( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, ): """Test states and events are purged only if they occurred before "now() - keep_days".""" @@ -651,8 +661,8 @@ async def test_purge_cutoff_date( @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_filtered_states( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, use_sqlite: bool, ): """Test filtered states are purged.""" @@ -837,8 +847,8 @@ async def test_purge_filtered_states( @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_filtered_states_to_empty( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, use_sqlite: bool, ): """Test filtered states are purged all the way to an empty db.""" @@ -890,8 +900,8 @@ async def test_purge_filtered_states_to_empty( @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_without_state_attributes_filtered_states_to_empty( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, use_sqlite: bool, ): """Test filtered legacy states without state attributes are purged all the way to an empty db.""" @@ -964,8 +974,8 @@ async def test_purge_without_state_attributes_filtered_states_to_empty( async def test_purge_filtered_events( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, ): """Test filtered events are purged.""" config: ConfigType = {"exclude": {"event_types": ["EVENT_PURGE"]}} @@ -1052,8 +1062,8 @@ async def test_purge_filtered_events( async def test_purge_filtered_events_state_changed( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, ): """Test filtered state_changed events are purged. This should also remove all states.""" config: ConfigType = {"exclude": {"event_types": [EVENT_STATE_CHANGED]}} @@ -1155,7 +1165,7 @@ async def test_purge_filtered_events_state_changed( async def test_purge_entities( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test purging of specific entities.""" await async_setup_recorder_instance(hass) @@ -1527,7 +1537,7 @@ def _add_state_and_state_changed_event( async def test_purge_many_old_events( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test deleting old events.""" instance = await async_setup_recorder_instance(hass) @@ -1580,7 +1590,7 @@ async def test_purge_many_old_events( async def test_purge_can_mix_legacy_and_new_format( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test purging with legacy a new events.""" instance = await async_setup_recorder_instance(hass) diff --git a/tests/components/recorder/test_run_history.py b/tests/components/recorder/test_run_history.py index ff4a5e5d701998a9d521e150f17148d4175c7e17..7504404f779715026270d10e5264620a9eb8193b 100644 --- a/tests/components/recorder/test_run_history.py +++ b/tests/components/recorder/test_run_history.py @@ -8,7 +8,7 @@ from homeassistant.components.recorder.models import process_timestamp from homeassistant.util import dt as dt_util -async def test_run_history(hass, recorder_mock): +async def test_run_history(recorder_mock, hass): """Test the run history gives the correct run.""" instance = recorder.get_instance(hass) now = dt_util.utcnow() diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 6d96b97b89ca7968cb51cb83c50d0dfa5cb80e63..aae6fcf91cbfada78ac26b327f6b918424139d50 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -448,9 +448,9 @@ def test_statistics_duplicated(hass_recorder, caplog): ), ) async def test_import_statistics( + recorder_mock, hass, hass_ws_client, - recorder_mock, caplog, source, statistic_id, @@ -885,10 +885,148 @@ def test_import_statistics_errors(hass_recorder, caplog): assert get_metadata(hass, statistic_ids=("sensor.total_energy_import",)) == {} +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +def test_weekly_statistics(hass_recorder, caplog, timezone): + """Test weekly statistics.""" + dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) + + hass = hass_recorder() + wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-09 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-16 23:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + ) + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata, external_statistics) + wait_recording_done(hass) + stats = statistics_during_period(hass, zero, period="week") + week1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + week1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) + week2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) + week2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-17 00:00:00")) + assert stats == { + "test:total_energy_import": [ + { + "statistic_id": "test:total_energy_import", + "start": week1_start.isoformat(), + "end": week1_end.isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "statistic_id": "test:total_energy_import", + "start": week2_start.isoformat(), + "end": week2_end.isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + ] + } + + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=["not", "the", "same", "test:total_energy_import"], + period="week", + ) + assert stats == { + "test:total_energy_import": [ + { + "statistic_id": "test:total_energy_import", + "start": week1_start.isoformat(), + "end": week1_end.isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "statistic_id": "test:total_energy_import", + "start": week2_start.isoformat(), + "end": week2_end.isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + ] + } + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=["test:total_energy_import", "with_other"], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="month" + ) + assert stats == {} + + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) + + @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") def test_monthly_statistics(hass_recorder, caplog, timezone): - """Test inserting external statistics.""" + """Test monthly statistics.""" dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) hass = hass_recorder() diff --git a/tests/components/recorder/test_system_health.py b/tests/components/recorder/test_system_health.py index b465ee89ebeb78e4f8326a121dac9533aa6ab0f1..0bb440a2dc89a8662b5fe04db3770b247a057c84 100644 --- a/tests/components/recorder/test_system_health.py +++ b/tests/components/recorder/test_system_health.py @@ -14,8 +14,12 @@ from .common import async_wait_recording_done from tests.common import SetupRecorderInstanceT, get_system_health_info -async def test_recorder_system_health(hass, recorder_mock): +async def test_recorder_system_health(recorder_mock, hass, recorder_db_url): """Test recorder system health.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite + return + assert await async_setup_component(hass, "system_health", {}) await async_wait_recording_done(hass) info = await get_system_health_info(hass, "recorder") @@ -32,7 +36,7 @@ async def test_recorder_system_health(hass, recorder_mock): @pytest.mark.parametrize( "dialect_name", [SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL] ) -async def test_recorder_system_health_alternate_dbms(hass, recorder_mock, dialect_name): +async def test_recorder_system_health_alternate_dbms(recorder_mock, hass, dialect_name): """Test recorder system health.""" assert await async_setup_component(hass, "system_health", {}) await async_wait_recording_done(hass) @@ -57,7 +61,7 @@ async def test_recorder_system_health_alternate_dbms(hass, recorder_mock, dialec "dialect_name", [SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL] ) async def test_recorder_system_health_db_url_missing_host( - hass, recorder_mock, dialect_name + recorder_mock, hass, dialect_name ): """Test recorder system health with a db_url without a hostname.""" assert await async_setup_component(hass, "system_health", {}) @@ -85,9 +89,15 @@ async def test_recorder_system_health_db_url_missing_host( async def test_recorder_system_health_crashed_recorder_runs_table( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, + recorder_db_url: str, ): """Test recorder system health with crashed recorder runs table.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite + return + with patch("homeassistant.components.recorder.run_history.RunHistory.load_from_db"): assert await async_setup_component(hass, "system_health", {}) instance = await async_setup_recorder_instance(hass) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 70f4eb0da70a61e8b59f1c4cecf296a5ae4b72d7..9000379c17df760387d812602f41cfc35a229693 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -40,8 +40,14 @@ def test_session_scope_not_setup(hass_recorder): pass -def test_recorder_bad_commit(hass_recorder): +def test_recorder_bad_commit(hass_recorder, recorder_db_url): """Bad _commit should retry 3 times.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite: mysql does not raise an OperationalError + # which triggers retries for the bad query below, it raises ProgrammingError + # on which we give up + return + hass = hass_recorder() def work(session): @@ -542,8 +548,12 @@ def test_warn_unsupported_dialect(caplog, dialect, message): assert message in caplog.text -def test_basic_sanity_check(hass_recorder): +def test_basic_sanity_check(hass_recorder, recorder_db_url): """Test the basic sanity checks with a missing table.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite + return + hass = hass_recorder() cursor = util.get_instance(hass).engine.raw_connection().cursor() @@ -556,8 +566,12 @@ def test_basic_sanity_check(hass_recorder): util.basic_sanity_check(cursor) -def test_combined_checks(hass_recorder, caplog): +def test_combined_checks(hass_recorder, caplog, recorder_db_url): """Run Checks on the open database.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite + return + hass = hass_recorder() instance = util.get_instance(hass) instance.db_retry_wait = 0 @@ -635,8 +649,12 @@ def test_end_incomplete_runs(hass_recorder, caplog): assert "Ended unfinished session" in caplog.text -def test_periodic_db_cleanups(hass_recorder): +def test_periodic_db_cleanups(hass_recorder, recorder_db_url): """Test periodic db cleanups.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite + return + hass = hass_recorder() with patch.object(util.get_instance(hass).engine, "connect") as connect_mock: util.periodic_db_cleanups(util.get_instance(hass)) @@ -651,8 +669,8 @@ def test_periodic_db_cleanups(hass_recorder): @patch("homeassistant.components.recorder.pool.check_loop") async def test_write_lock_db( skip_check_loop, - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, tmp_path, ): """Test database write lock.""" diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 6058fd6a2e537b860be18f00187f152d54d17e76..00e9d0d35b4e6b1d26741f0f7a6ce6e921add281 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -1,14 +1,17 @@ """The tests for sensor recorder platform.""" # pylint: disable=protected-access,invalid-name +import datetime from datetime import timedelta +from statistics import fmean import threading -from unittest.mock import patch +from unittest.mock import ANY, patch from freezegun import freeze_time import pytest from pytest import approx from homeassistant.components import recorder +from homeassistant.components.recorder.db_schema import Statistics, StatisticsShortTerm from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -19,7 +22,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .common import ( async_recorder_block_till_done, @@ -122,11 +125,11 @@ VOLUME_SENSOR_M3_ATTRIBUTES_TOTAL = { } -async def test_statistics_during_period(hass, hass_ws_client, recorder_mock): +async def test_statistics_during_period(recorder_mock, hass, hass_ws_client): """Test statistics_during_period.""" now = dt_util.utcnow() - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.test", 10, attributes=POWER_SENSOR_KW_ATTRIBUTES) @@ -178,6 +181,448 @@ async def test_statistics_during_period(hass, hass_ws_client, recorder_mock): } +@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +async def test_statistic_during_period(recorder_mock, hass, hass_ws_client): + """Test statistic_during_period.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + now = dt_util.utcnow() + + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + zero = now + start = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=-3) + + imported_stats_5min = [ + { + "start": (start + timedelta(minutes=5 * i)), + "max": i * 2, + "mean": i, + "min": -76 + i * 2, + "sum": i, + } + for i in range(0, 39) + ] + imported_stats = [ + { + "start": imported_stats_5min[i * 12]["start"], + "max": max( + stat["max"] for stat in imported_stats_5min[i * 12 : (i + 1) * 12] + ), + "mean": fmean( + stat["mean"] for stat in imported_stats_5min[i * 12 : (i + 1) * 12] + ), + "min": min( + stat["min"] for stat in imported_stats_5min[i * 12 : (i + 1) * 12] + ), + "sum": imported_stats_5min[i * 12 + 11]["sum"], + } + for i in range(0, 3) + ] + imported_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.test", + "unit_of_measurement": "kWh", + } + + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats, + Statistics, + ) + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats_5min, + StatisticsShortTerm, + ) + await async_wait_recording_done(hass) + + # No data for this period yet + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": now.isoformat(), + "end_time": now.isoformat(), + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": None, + "mean": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[:] + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[:]), + "min": min(stat["min"] for stat in imported_stats_5min[:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"], + } + + # This should also include imported_statistics_5min[:] + start_time = "2022-10-21T04:00:00+00:00" + end_time = "2022-10-21T07:15:00+00:00" + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[:]), + "min": min(stat["min"] for stat in imported_stats_5min[:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"], + } + + # This should also include imported_statistics_5min[:] + start_time = "2022-10-20T04:00:00+00:00" + end_time = "2022-10-21T08:20:00+00:00" + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[:]), + "min": min(stat["min"] for stat in imported_stats_5min[:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"], + } + + # This should include imported_statistics_5min[26:] + start_time = "2022-10-21T06:10:00+00:00" + assert imported_stats_5min[26]["start"].isoformat() == start_time + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[26:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[26:]), + "min": min(stat["min"] for stat in imported_stats_5min[26:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[25]["sum"], + } + + # This should also include imported_statistics_5min[26:] + start_time = "2022-10-21T06:09:00+00:00" + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[26:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[26:]), + "min": min(stat["min"] for stat in imported_stats_5min[26:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[25]["sum"], + } + + # This should include imported_statistics_5min[:26] + end_time = "2022-10-21T06:10:00+00:00" + assert imported_stats_5min[26]["start"].isoformat() == end_time + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "fixed_period": { + "end_time": end_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:26]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[:26]), + "min": min(stat["min"] for stat in imported_stats_5min[:26]), + "change": imported_stats_5min[25]["sum"] - 0, + } + + # This should include imported_statistics_5min[26:32] (less than a full hour) + start_time = "2022-10-21T06:10:00+00:00" + assert imported_stats_5min[26]["start"].isoformat() == start_time + end_time = "2022-10-21T06:40:00+00:00" + assert imported_stats_5min[32]["start"].isoformat() == end_time + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[26:32]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[26:32]), + "min": min(stat["min"] for stat in imported_stats_5min[26:32]), + "change": imported_stats_5min[31]["sum"] - imported_stats_5min[25]["sum"], + } + + # This should include imported_statistics[2:] + imported_statistics_5min[36:] + start_time = "2022-10-21T06:00:00+00:00" + assert imported_stats_5min[24]["start"].isoformat() == start_time + assert imported_stats[2]["start"].isoformat() == start_time + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[24:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[24:]), + "min": min(stat["min"] for stat in imported_stats_5min[24:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[23]["sum"], + } + + # This should also include imported_statistics[2:] + imported_statistics_5min[36:] + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "rolling_window": { + "duration": {"hours": 1, "minutes": 25}, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[24:]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[24:]), + "min": min(stat["min"] for stat in imported_stats_5min[24:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[23]["sum"], + } + + # This should include imported_statistics[2:3] + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "rolling_window": { + "duration": {"hours": 1}, + "offset": {"minutes": -25}, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[24:36]), + "mean": fmean(stat["mean"] for stat in imported_stats_5min[24:36]), + "min": min(stat["min"] for stat in imported_stats_5min[24:36]), + "change": imported_stats_5min[35]["sum"] - imported_stats_5min[23]["sum"], + } + + # Test we can get only selected types + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "types": ["max", "change"], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:]), + "change": imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"], + } + + # Test we can convert units + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "units": {"energy": "MWh"}, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:]) / 1000, + "mean": fmean(stat["mean"] for stat in imported_stats_5min[:]) / 1000, + "min": min(stat["min"] for stat in imported_stats_5min[:]) / 1000, + "change": (imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"]) + / 1000, + } + + # Test we can automatically convert units + hass.states.async_set("sensor.test", None, attributes=ENERGY_SENSOR_WH_ATTRIBUTES) + await client.send_json( + { + "id": next_id(), + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": max(stat["max"] for stat in imported_stats_5min[:]) * 1000, + "mean": fmean(stat["mean"] for stat in imported_stats_5min[:]) * 1000, + "min": min(stat["min"] for stat in imported_stats_5min[:]) * 1000, + "change": (imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"]) + * 1000, + } + + +@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +@pytest.mark.parametrize( + "calendar_period, start_time, end_time", + ( + ( + {"period": "hour"}, + "2022-10-21T07:00:00+00:00", + "2022-10-21T08:00:00+00:00", + ), + ( + {"period": "hour", "offset": -1}, + "2022-10-21T06:00:00+00:00", + "2022-10-21T07:00:00+00:00", + ), + ( + {"period": "day"}, + "2022-10-21T07:00:00+00:00", + "2022-10-22T07:00:00+00:00", + ), + ( + {"period": "day", "offset": -1}, + "2022-10-20T07:00:00+00:00", + "2022-10-21T07:00:00+00:00", + ), + ( + {"period": "week"}, + "2022-10-17T07:00:00+00:00", + "2022-10-24T07:00:00+00:00", + ), + ( + {"period": "week", "offset": -1}, + "2022-10-10T07:00:00+00:00", + "2022-10-17T07:00:00+00:00", + ), + ( + {"period": "month"}, + "2022-10-01T07:00:00+00:00", + "2022-11-01T07:00:00+00:00", + ), + ( + {"period": "month", "offset": -1}, + "2022-09-01T07:00:00+00:00", + "2022-10-01T07:00:00+00:00", + ), + ( + {"period": "year"}, + "2022-01-01T08:00:00+00:00", + "2023-01-01T08:00:00+00:00", + ), + ( + {"period": "year", "offset": -1}, + "2021-01-01T08:00:00+00:00", + "2022-01-01T08:00:00+00:00", + ), + ), +) +async def test_statistic_during_period_calendar( + recorder_mock, hass, hass_ws_client, calendar_period, start_time, end_time +): + """Test statistic_during_period.""" + client = await hass_ws_client() + + # Try requesting data for the current hour + with patch( + "homeassistant.components.recorder.websocket_api.statistic_during_period", + return_value={}, + ) as statistic_during_period: + await client.send_json( + { + "id": 1, + "type": "recorder/statistic_during_period", + "calendar": calendar_period, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + statistic_during_period.assert_called_once_with( + hass, ANY, ANY, "sensor.test", None, units=None + ) + assert statistic_during_period.call_args[0][1].isoformat() == start_time + assert statistic_during_period.call_args[0][2].isoformat() == end_time + assert response["success"] + + @pytest.mark.parametrize( "attributes, state, value, custom_units, converted_value", [ @@ -200,9 +645,9 @@ async def test_statistics_during_period(hass, hass_ws_client, recorder_mock): ], ) async def test_statistics_during_period_unit_conversion( + recorder_mock, hass, hass_ws_client, - recorder_mock, attributes, state, value, @@ -293,9 +738,9 @@ async def test_statistics_during_period_unit_conversion( ], ) async def test_sum_statistics_during_period_unit_conversion( + recorder_mock, hass, hass_ws_client, - recorder_mock, attributes, state, value, @@ -386,7 +831,7 @@ async def test_sum_statistics_during_period_unit_conversion( ], ) async def test_statistics_during_period_invalid_unit_conversion( - hass, hass_ws_client, recorder_mock, custom_units + recorder_mock, hass, hass_ws_client, custom_units ): """Test statistics_during_period.""" now = dt_util.utcnow() @@ -425,13 +870,13 @@ async def test_statistics_during_period_invalid_unit_conversion( async def test_statistics_during_period_in_the_past( - hass, hass_ws_client, recorder_mock + recorder_mock, hass, hass_ws_client ): """Test statistics_during_period in the past.""" hass.config.set_time_zone("UTC") now = dt_util.utcnow().replace() - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) @@ -548,7 +993,7 @@ async def test_statistics_during_period_in_the_past( async def test_statistics_during_period_bad_start_time( - hass, hass_ws_client, recorder_mock + recorder_mock, hass, hass_ws_client ): """Test statistics_during_period.""" client = await hass_ws_client() @@ -566,7 +1011,7 @@ async def test_statistics_during_period_bad_start_time( async def test_statistics_during_period_bad_end_time( - hass, hass_ws_client, recorder_mock + recorder_mock, hass, hass_ws_client ): """Test statistics_during_period.""" now = dt_util.utcnow() @@ -589,34 +1034,64 @@ async def test_statistics_during_period_bad_end_time( @pytest.mark.parametrize( "units, attributes, display_unit, statistics_unit, unit_class", [ - (IMPERIAL_SYSTEM, DISTANCE_SENSOR_M_ATTRIBUTES, "m", "m", "distance"), + (US_CUSTOMARY_SYSTEM, DISTANCE_SENSOR_M_ATTRIBUTES, "m", "m", "distance"), (METRIC_SYSTEM, DISTANCE_SENSOR_M_ATTRIBUTES, "m", "m", "distance"), - (IMPERIAL_SYSTEM, DISTANCE_SENSOR_FT_ATTRIBUTES, "ft", "ft", "distance"), + ( + US_CUSTOMARY_SYSTEM, + DISTANCE_SENSOR_FT_ATTRIBUTES, + "ft", + "ft", + "distance", + ), (METRIC_SYSTEM, DISTANCE_SENSOR_FT_ATTRIBUTES, "ft", "ft", "distance"), - (IMPERIAL_SYSTEM, ENERGY_SENSOR_WH_ATTRIBUTES, "Wh", "Wh", "energy"), + (US_CUSTOMARY_SYSTEM, ENERGY_SENSOR_WH_ATTRIBUTES, "Wh", "Wh", "energy"), (METRIC_SYSTEM, ENERGY_SENSOR_WH_ATTRIBUTES, "Wh", "Wh", "energy"), - (IMPERIAL_SYSTEM, GAS_SENSOR_FT3_ATTRIBUTES, "ft³", "ft³", "volume"), + (US_CUSTOMARY_SYSTEM, GAS_SENSOR_FT3_ATTRIBUTES, "ft³", "ft³", "volume"), (METRIC_SYSTEM, GAS_SENSOR_FT3_ATTRIBUTES, "ft³", "ft³", "volume"), - (IMPERIAL_SYSTEM, POWER_SENSOR_KW_ATTRIBUTES, "kW", "kW", "power"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_KW_ATTRIBUTES, "kW", "kW", "power"), (METRIC_SYSTEM, POWER_SENSOR_KW_ATTRIBUTES, "kW", "kW", "power"), - (IMPERIAL_SYSTEM, PRESSURE_SENSOR_HPA_ATTRIBUTES, "hPa", "hPa", "pressure"), + ( + US_CUSTOMARY_SYSTEM, + PRESSURE_SENSOR_HPA_ATTRIBUTES, + "hPa", + "hPa", + "pressure", + ), (METRIC_SYSTEM, PRESSURE_SENSOR_HPA_ATTRIBUTES, "hPa", "hPa", "pressure"), - (IMPERIAL_SYSTEM, SPEED_SENSOR_KPH_ATTRIBUTES, "km/h", "km/h", "speed"), + (US_CUSTOMARY_SYSTEM, SPEED_SENSOR_KPH_ATTRIBUTES, "km/h", "km/h", "speed"), (METRIC_SYSTEM, SPEED_SENSOR_KPH_ATTRIBUTES, "km/h", "km/h", "speed"), - (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_C_ATTRIBUTES, "°C", "°C", "temperature"), + ( + US_CUSTOMARY_SYSTEM, + TEMPERATURE_SENSOR_C_ATTRIBUTES, + "°C", + "°C", + "temperature", + ), (METRIC_SYSTEM, TEMPERATURE_SENSOR_C_ATTRIBUTES, "°C", "°C", "temperature"), - (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_F_ATTRIBUTES, "°F", "°F", "temperature"), + ( + US_CUSTOMARY_SYSTEM, + TEMPERATURE_SENSOR_F_ATTRIBUTES, + "°F", + "°F", + "temperature", + ), (METRIC_SYSTEM, TEMPERATURE_SENSOR_F_ATTRIBUTES, "°F", "°F", "temperature"), - (IMPERIAL_SYSTEM, VOLUME_SENSOR_FT3_ATTRIBUTES, "ft³", "ft³", "volume"), + (US_CUSTOMARY_SYSTEM, VOLUME_SENSOR_FT3_ATTRIBUTES, "ft³", "ft³", "volume"), (METRIC_SYSTEM, VOLUME_SENSOR_FT3_ATTRIBUTES, "ft³", "ft³", "volume"), - (IMPERIAL_SYSTEM, VOLUME_SENSOR_FT3_ATTRIBUTES_TOTAL, "ft³", "ft³", "volume"), + ( + US_CUSTOMARY_SYSTEM, + VOLUME_SENSOR_FT3_ATTRIBUTES_TOTAL, + "ft³", + "ft³", + "volume", + ), (METRIC_SYSTEM, VOLUME_SENSOR_FT3_ATTRIBUTES_TOTAL, "ft³", "ft³", "volume"), ], ) async def test_list_statistic_ids( + recorder_mock, hass, hass_ws_client, - recorder_mock, units, attributes, display_unit, @@ -724,7 +1199,7 @@ async def test_list_statistic_ids( assert response["result"] == [] -async def test_validate_statistics(hass, hass_ws_client, recorder_mock): +async def test_validate_statistics(recorder_mock, hass, hass_ws_client): """Test validate_statistics can be called.""" id = 1 @@ -746,7 +1221,7 @@ async def test_validate_statistics(hass, hass_ws_client, recorder_mock): await assert_validation_result(client, {}) -async def test_clear_statistics(hass, hass_ws_client, recorder_mock): +async def test_clear_statistics(recorder_mock, hass, hass_ws_client): """Test removing statistics.""" now = dt_util.utcnow() @@ -873,7 +1348,7 @@ async def test_clear_statistics(hass, hass_ws_client, recorder_mock): "new_unit, new_unit_class", [("dogs", None), (None, None), ("W", "power")] ) async def test_update_statistics_metadata( - hass, hass_ws_client, recorder_mock, new_unit, new_unit_class + recorder_mock, hass, hass_ws_client, new_unit, new_unit_class ): """Test removing statistics.""" now = dt_util.utcnow() @@ -964,7 +1439,7 @@ async def test_update_statistics_metadata( } -async def test_change_statistics_unit(hass, hass_ws_client, recorder_mock): +async def test_change_statistics_unit(recorder_mock, hass, hass_ws_client): """Test change unit of recorded statistics.""" now = dt_util.utcnow() @@ -1083,7 +1558,7 @@ async def test_change_statistics_unit(hass, hass_ws_client, recorder_mock): async def test_change_statistics_unit_errors( - hass, hass_ws_client, recorder_mock, caplog + recorder_mock, hass, hass_ws_client, caplog ): """Test change unit of recorded statistics.""" now = dt_util.utcnow() @@ -1200,7 +1675,7 @@ async def test_change_statistics_unit_errors( await assert_statistics(expected_statistics) -async def test_recorder_info(hass, hass_ws_client, recorder_mock): +async def test_recorder_info(recorder_mock, hass, hass_ws_client): """Test getting recorder status.""" client = await hass_ws_client() @@ -1329,9 +1804,13 @@ async def test_backup_start_no_recorder( async def test_backup_start_timeout( - hass, hass_ws_client, hass_supervisor_access_token, recorder_mock + recorder_mock, hass, hass_ws_client, hass_supervisor_access_token, recorder_db_url ): """Test getting backup start when recorder is not present.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite: Locking is not implemented for other engines + return + client = await hass_ws_client(hass, hass_supervisor_access_token) # Ensure there are no queued events @@ -1348,7 +1827,7 @@ async def test_backup_start_timeout( async def test_backup_end( - hass, hass_ws_client, hass_supervisor_access_token, recorder_mock + recorder_mock, hass, hass_ws_client, hass_supervisor_access_token ): """Test backup start.""" client = await hass_ws_client(hass, hass_supervisor_access_token) @@ -1366,9 +1845,13 @@ async def test_backup_end( async def test_backup_end_without_start( - hass, hass_ws_client, hass_supervisor_access_token, recorder_mock + recorder_mock, hass, hass_ws_client, hass_supervisor_access_token, recorder_db_url ): """Test backup start.""" + if recorder_db_url.startswith("mysql://"): + # This test is specific for SQLite: Locking is not implemented for other engines + return + client = await hass_ws_client(hass, hass_supervisor_access_token) # Ensure there are no queued events @@ -1400,7 +1883,7 @@ async def test_backup_end_without_start( ], ) async def test_get_statistics_metadata( - hass, hass_ws_client, recorder_mock, units, attributes, unit, unit_class + recorder_mock, hass, hass_ws_client, units, attributes, unit, unit_class ): """Test get_statistics_metadata.""" now = dt_util.utcnow() @@ -1545,7 +2028,7 @@ async def test_get_statistics_metadata( ), ) async def test_import_statistics( - hass, hass_ws_client, recorder_mock, caplog, source, statistic_id + recorder_mock, hass, hass_ws_client, caplog, source, statistic_id ): """Test importing statistics.""" client = await hass_ws_client() @@ -1557,20 +2040,20 @@ async def test_import_statistics( period1 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) period2 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=2) - external_statistics1 = { + imported_statistics1 = { "start": period1.isoformat(), "last_reset": None, "state": 0, "sum": 2, } - external_statistics2 = { + imported_statistics2 = { "start": period2.isoformat(), "last_reset": None, "state": 1, "sum": 3, } - external_metadata = { + imported_metadata = { "has_mean": False, "has_sum": True, "name": "Total imported energy", @@ -1583,8 +2066,8 @@ async def test_import_statistics( { "id": 1, "type": "recorder/import_statistics", - "metadata": external_metadata, - "stats": [external_statistics1, external_statistics2], + "metadata": imported_metadata, + "stats": [imported_statistics1, imported_statistics2], } ) response = await client.receive_json() @@ -1674,7 +2157,7 @@ async def test_import_statistics( { "id": 2, "type": "recorder/import_statistics", - "metadata": external_metadata, + "metadata": imported_metadata, "stats": [external_statistics], } ) @@ -1726,7 +2209,7 @@ async def test_import_statistics( { "id": 3, "type": "recorder/import_statistics", - "metadata": external_metadata, + "metadata": imported_metadata, "stats": [external_statistics], } ) @@ -1772,7 +2255,7 @@ async def test_import_statistics( ), ) async def test_adjust_sum_statistics_energy( - hass, hass_ws_client, recorder_mock, caplog, source, statistic_id + recorder_mock, hass, hass_ws_client, caplog, source, statistic_id ): """Test adjusting statistics.""" client = await hass_ws_client() @@ -1784,20 +2267,20 @@ async def test_adjust_sum_statistics_energy( period1 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) period2 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=2) - external_statistics1 = { + imported_statistics1 = { "start": period1.isoformat(), "last_reset": None, "state": 0, "sum": 2, } - external_statistics2 = { + imported_statistics2 = { "start": period2.isoformat(), "last_reset": None, "state": 1, "sum": 3, } - external_metadata = { + imported_metadata = { "has_mean": False, "has_sum": True, "name": "Total imported energy", @@ -1810,8 +2293,8 @@ async def test_adjust_sum_statistics_energy( { "id": 1, "type": "recorder/import_statistics", - "metadata": external_metadata, - "stats": [external_statistics1, external_statistics2], + "metadata": imported_metadata, + "stats": [imported_statistics1, imported_statistics2], } ) response = await client.receive_json() @@ -1968,7 +2451,7 @@ async def test_adjust_sum_statistics_energy( ), ) async def test_adjust_sum_statistics_gas( - hass, hass_ws_client, recorder_mock, caplog, source, statistic_id + recorder_mock, hass, hass_ws_client, caplog, source, statistic_id ): """Test adjusting statistics.""" client = await hass_ws_client() @@ -1980,20 +2463,20 @@ async def test_adjust_sum_statistics_gas( period1 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) period2 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=2) - external_statistics1 = { + imported_statistics1 = { "start": period1.isoformat(), "last_reset": None, "state": 0, "sum": 2, } - external_statistics2 = { + imported_statistics2 = { "start": period2.isoformat(), "last_reset": None, "state": 1, "sum": 3, } - external_metadata = { + imported_metadata = { "has_mean": False, "has_sum": True, "name": "Total imported energy", @@ -2006,8 +2489,8 @@ async def test_adjust_sum_statistics_gas( { "id": 1, "type": "recorder/import_statistics", - "metadata": external_metadata, - "stats": [external_statistics1, external_statistics2], + "metadata": imported_metadata, + "stats": [imported_statistics1, imported_statistics2], } ) response = await client.receive_json() @@ -2168,9 +2651,9 @@ async def test_adjust_sum_statistics_gas( ), ) async def test_adjust_sum_statistics_errors( + recorder_mock, hass, hass_ws_client, - recorder_mock, caplog, state_unit, statistic_unit, @@ -2191,20 +2674,20 @@ async def test_adjust_sum_statistics_errors( period1 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) period2 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=2) - external_statistics1 = { + imported_statistics1 = { "start": period1.isoformat(), "last_reset": None, "state": 0, "sum": 2, } - external_statistics2 = { + imported_statistics2 = { "start": period2.isoformat(), "last_reset": None, "state": 1, "sum": 3, } - external_metadata = { + imported_metadata = { "has_mean": False, "has_sum": True, "name": "Total imported energy", @@ -2217,8 +2700,8 @@ async def test_adjust_sum_statistics_errors( { "id": 1, "type": "recorder/import_statistics", - "metadata": external_metadata, - "stats": [external_statistics1, external_statistics2], + "metadata": imported_metadata, + "stats": [imported_statistics1, imported_statistics2], } ) response = await client.receive_json() diff --git a/tests/components/ridwell/conftest.py b/tests/components/ridwell/conftest.py index 221ce5be8d2a1b02a89a7eb185f94530deb3546d..c4ff094638b0e5c7978379bd938ed16f94773d0e 100644 --- a/tests/components/ridwell/conftest.py +++ b/tests/components/ridwell/conftest.py @@ -47,9 +47,9 @@ def client_fixture(account): @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, unique_id): +def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_USERNAME], data=config) entry.add_to_hass(hass) return entry @@ -77,9 +77,3 @@ async def setup_ridwell_fixture(hass, client, config): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "user@email.com" diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py index 8427fa13e11aed346263ad6c91c888c4691f8a0f..96d1531ac84a7d2cbe343ae8f7d269ff6e2f8ae8 100644 --- a/tests/components/ridwell/test_diagnostics.py +++ b/tests/components/ridwell/test_diagnostics.py @@ -1,10 +1,25 @@ """Test Ridwell diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + from tests.components.diagnostics import get_diagnostics_for_config_entry async def test_entry_diagnostics(hass, config_entry, hass_client, setup_ridwell): """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "entry_id": config_entry.entry_id, + "version": 2, + "domain": "ridwell", + "title": REDACTED, + "data": {"username": REDACTED, "password": REDACTED}, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + }, "data": [ { "_async_request": None, @@ -31,5 +46,5 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_ridwell) "repr": "<EventState.INITIALIZED: 'initialized'>", }, } - ] + ], } diff --git a/tests/components/risco/test_binary_sensor.py b/tests/components/risco/test_binary_sensor.py index 2325d88c03faa90d60d411d97c7f5a62974f3f90..71cbd04f39145877f55c679ef7c3bc58deccaab4 100644 --- a/tests/components/risco/test_binary_sensor.py +++ b/tests/components/risco/test_binary_sensor.py @@ -13,6 +13,8 @@ from .util import TEST_SITE_UUID, zone_mock FIRST_ENTITY_ID = "binary_sensor.zone_0" SECOND_ENTITY_ID = "binary_sensor.zone_1" +FIRST_ALARMED_ENTITY_ID = FIRST_ENTITY_ID + "_alarmed" +SECOND_ALARMED_ENTITY_ID = SECOND_ENTITY_ID + "_alarmed" @pytest.fixture @@ -23,10 +25,14 @@ def two_zone_local(): zone_mocks[0], "id", new_callable=PropertyMock(return_value=0) ), patch.object( zone_mocks[0], "name", new_callable=PropertyMock(return_value="Zone 0") + ), patch.object( + zone_mocks[0], "alarmed", new_callable=PropertyMock(return_value=False) ), patch.object( zone_mocks[1], "id", new_callable=PropertyMock(return_value=1) ), patch.object( zone_mocks[1], "name", new_callable=PropertyMock(return_value="Zone 1") + ), patch.object( + zone_mocks[1], "alarmed", new_callable=PropertyMock(return_value=False) ), patch( "homeassistant.components.risco.RiscoLocal.partitions", new_callable=PropertyMock(return_value={}), @@ -126,6 +132,8 @@ async def test_error_on_connect(hass, connect_with_error, local_config_entry): registry = er.async_get(hass) assert not registry.async_is_registered(FIRST_ENTITY_ID) assert not registry.async_is_registered(SECOND_ENTITY_ID) + assert not registry.async_is_registered(FIRST_ALARMED_ENTITY_ID) + assert not registry.async_is_registered(SECOND_ALARMED_ENTITY_ID) async def test_local_setup(hass, two_zone_local, setup_risco_local): @@ -133,6 +141,8 @@ async def test_local_setup(hass, two_zone_local, setup_risco_local): registry = er.async_get(hass) assert registry.async_is_registered(FIRST_ENTITY_ID) assert registry.async_is_registered(SECOND_ENTITY_ID) + assert registry.async_is_registered(FIRST_ALARMED_ENTITY_ID) + assert registry.async_is_registered(SECOND_ALARMED_ENTITY_ID) registry = dr.async_get(hass) device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_zone_0_local")}) @@ -157,6 +167,7 @@ async def _check_local_state( new_callable=PropertyMock(return_value=bypassed), ): await callback(zone_id, zones[zone_id]) + await hass.async_block_till_done() expected_triggered = STATE_ON if triggered else STATE_OFF assert hass.states.get(entity_id).state == expected_triggered @@ -164,6 +175,22 @@ async def _check_local_state( assert hass.states.get(entity_id).attributes["zone_id"] == zone_id +async def _check_alarmed_local_state( + hass, zones, alarmed, entity_id, zone_id, callback +): + with patch.object( + zones[zone_id], + "alarmed", + new_callable=PropertyMock(return_value=alarmed), + ): + await callback(zone_id, zones[zone_id]) + await hass.async_block_till_done() + + expected_alarmed = STATE_ON if alarmed else STATE_OFF + assert hass.states.get(entity_id).state == expected_alarmed + assert hass.states.get(entity_id).attributes["zone_id"] == zone_id + + @pytest.fixture def _mock_zone_handler(): with patch("homeassistant.components.risco.RiscoLocal.add_zone_handler") as mock: @@ -204,6 +231,28 @@ async def test_local_states( ) +async def test_alarmed_local_states( + hass, two_zone_local, _mock_zone_handler, setup_risco_local +): + """Test the various alarm states.""" + callback = _mock_zone_handler.call_args.args[0] + + assert callback is not None + + await _check_alarmed_local_state( + hass, two_zone_local, True, FIRST_ALARMED_ENTITY_ID, 0, callback + ) + await _check_alarmed_local_state( + hass, two_zone_local, False, FIRST_ALARMED_ENTITY_ID, 0, callback + ) + await _check_alarmed_local_state( + hass, two_zone_local, True, SECOND_ALARMED_ENTITY_ID, 1, callback + ) + await _check_alarmed_local_state( + hass, two_zone_local, False, SECOND_ALARMED_ENTITY_ID, 1, callback + ) + + async def test_local_bypass(hass, two_zone_local, setup_risco_local): """Test bypassing a zone.""" with patch.object(two_zone_local[0], "bypass") as mock: diff --git a/tests/components/rtsp_to_webrtc/test_config_flow.py b/tests/components/rtsp_to_webrtc/test_config_flow.py index cca6395c3177ddd9ea519ecd43987ce9f24f5066..6386a942cc420f0bcbe8c34b6df17c053f42d741 100644 --- a/tests/components/rtsp_to_webrtc/test_config_flow.py +++ b/tests/components/rtsp_to_webrtc/test_config_flow.py @@ -124,7 +124,9 @@ async def test_hassio_discovery(hass): "addon": "RTSPtoWebRTC", "host": "fake-server", "port": 8083, - } + }, + name="RTSPtoWebRTC", + slug="rtsp-to-webrtc", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -161,7 +163,9 @@ async def test_hassio_single_config_entry(hass: HomeAssistant) -> None: "addon": "RTSPtoWebRTC", "host": "fake-server", "port": 8083, - } + }, + name="RTSPtoWebRTC", + slug="rtsp-to-webrtc", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -181,7 +185,9 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: "addon": "RTSPtoWebRTC", "host": "fake-server", "port": 8083, - } + }, + name="RTSPtoWebRTC", + slug="rtsp-to-webrtc", ), context={"source": config_entries.SOURCE_HASSIO}, ) @@ -198,7 +204,9 @@ async def test_hassio_discovery_server_failure(hass: HomeAssistant) -> None: "addon": "RTSPtoWebRTC", "host": "fake-server", "port": 8083, - } + }, + name="RTSPtoWebRTC", + slug="rtsp-to-webrtc", ), context={"source": config_entries.SOURCE_HASSIO}, ) diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 764022f350163275ac92e9bdaee616702d977d98..73ad642f7e7aae675fa97d0e6cc8fbb066ffbad7 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -32,6 +32,10 @@ async def silent_ssdp_scanner(hass): "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" + ), patch( + "homeassistant.components.ssdp.Server._async_start_upnp_servers" + ), patch( + "homeassistant.components.ssdp.Server._async_stop_upnp_servers" ): yield diff --git a/tests/components/schedule/test_recorder.py b/tests/components/schedule/test_recorder.py index a105f388fc29079b8ca8b36be7397d94477723cd..f6879da90d69aec02e92f26328f4667796b2e9cc 100644 --- a/tests/components/schedule/test_recorder.py +++ b/tests/components/schedule/test_recorder.py @@ -16,8 +16,8 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock: None, + hass: HomeAssistant, enable_custom_integrations: None, ) -> None: """Test attributes to be excluded.""" diff --git a/tests/components/scrape/__init__.py b/tests/components/scrape/__init__.py index 0ba9266a79d96423659b14beea59f935f191dcd8..644ea84854aa8eb986ebeb497b2cd06bbca6c903 100644 --- a/tests/components/scrape/__init__.py +++ b/tests/components/scrape/__init__.py @@ -18,6 +18,7 @@ def return_config( username=None, password=None, headers=None, + unique_id=None, ) -> dict[str, dict[str, Any]]: """Return config.""" config = { @@ -44,6 +45,8 @@ def return_config( config["password"] = password if headers: config["headers"] = headers + if unique_id: + config["unique_id"] = unique_id return config diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index d8da22aada140e7ddaaae37ce6d2dd88969ea6f0..aacd89b2eb9b4a8840ad8c8d325674df8c005955 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -1,8 +1,10 @@ """The tests for the Scrape sensor platform.""" from __future__ import annotations +from datetime import datetime from unittest.mock import patch +from homeassistant.components.scrape.sensor import SCAN_INTERVAL from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, @@ -11,15 +13,18 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from . import MockRestData, return_config +from tests.common import async_fire_time_changed + DOMAIN = "scrape" @@ -89,6 +94,34 @@ async def test_scrape_uom_and_classes(hass: HomeAssistant) -> None: assert state.attributes[CONF_STATE_CLASS] == SensorStateClass.MEASUREMENT +async def test_scrape_unique_id(hass: HomeAssistant) -> None: + """Test Scrape sensor for unique id.""" + config = { + "sensor": return_config( + select=".current-temp h3", + name="Current Temp", + template="{{ value.split(':')[1] }}", + unique_id="very_unique_id", + ) + } + + mocker = MockRestData("test_scrape_uom_and_classes") + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.current_temp") + assert state.state == "22.1" + + registry = er.async_get(hass) + entry = registry.async_get("sensor.current_temp") + assert entry + assert entry.unique_id == "very_unique_id" + + async def test_scrape_sensor_authentication(hass: HomeAssistant) -> None: """Test Scrape sensor with authentication.""" config = { @@ -155,12 +188,13 @@ async def test_scrape_sensor_no_data_refresh(hass: HomeAssistant) -> None: assert state assert state.state == "Current Version: 2021.12.10" - mocker.data = None - await async_update_entity(hass, "sensor.ha_version") + mocker.payload = "test_scrape_sensor_no_data" + async_fire_time_changed(hass, datetime.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() - assert mocker.data is None + state = hass.states.get("sensor.ha_version") assert state is not None - assert state.state == "Current Version: 2021.12.10" + assert state.state == STATE_UNAVAILABLE async def test_scrape_sensor_attribute_and_tag(hass: HomeAssistant) -> None: diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index b48a65275b7c31c78833650ab7fed030601ad2f0..09d2c3c70b1e51645f3d7ff5d585ccd180c16106 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -7,7 +7,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant.components import script -from homeassistant.components.script import DOMAIN, EVENT_SCRIPT_STARTED +from homeassistant.components.script import DOMAIN, EVENT_SCRIPT_STARTED, ScriptEntity from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, @@ -46,6 +46,12 @@ from tests.components.logbook.common import MockRow, mock_humanify ENTITY_ID = "script.test" +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "script") + + async def test_passing_variables(hass): """Test different ways of passing in variables.""" mock_restore_cache(hass, ()) @@ -219,6 +225,129 @@ async def test_reload_service(hass, running): assert hass.services.has_service(script.DOMAIN, "test") +async def test_reload_unchanged_does_not_stop(hass, calls): + """Test that reloading stops any running actions as appropriate.""" + test_entity = "test.entity" + + config = { + script.DOMAIN: { + "test": { + "sequence": [ + {"event": "running"}, + {"wait_template": "{{ is_state('test.entity', 'goodbye') }}"}, + {"service": "test.script"}, + ], + } + } + } + assert await async_setup_component(hass, script.DOMAIN, config) + + assert hass.states.get(ENTITY_ID) is not None + assert hass.services.has_service(script.DOMAIN, "test") + + running = asyncio.Event() + + @callback + def running_cb(event): + running.set() + + hass.bus.async_listen_once("running", running_cb) + hass.states.async_set(test_entity, "hello") + + # Start the script and wait for it to start + _, object_id = split_entity_id(ENTITY_ID) + await hass.services.async_call(DOMAIN, object_id) + await running.wait() + assert len(calls) == 0 + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call(script.DOMAIN, SERVICE_RELOAD, blocking=True) + + hass.states.async_set(test_entity, "goodbye") + await hass.async_block_till_done() + + assert len(calls) == 1 + + +@pytest.mark.parametrize( + "script_config", + ( + { + "test": { + "sequence": [{"service": "test.script"}], + } + }, + # A script using templates + { + "test": { + "sequence": [{"service": "{{ 'test.script' }}"}], + } + }, + # A script using blueprint + { + "test": { + "use_blueprint": { + "path": "test_service.yaml", + "input": { + "service_to_call": "test.script", + }, + } + } + }, + # A script using blueprint with templated input + { + "test": { + "use_blueprint": { + "path": "test_service.yaml", + "input": { + "service_to_call": "{{ 'test.script' }}", + }, + } + } + }, + ), +) +async def test_reload_unchanged_script(hass, calls, script_config): + """Test an unmodified script is not reloaded.""" + with patch( + "homeassistant.components.script.ScriptEntity", wraps=ScriptEntity + ) as script_entity_init: + config = {script.DOMAIN: [script_config]} + assert await async_setup_component(hass, script.DOMAIN, config) + assert hass.states.get(ENTITY_ID) is not None + assert hass.services.has_service(script.DOMAIN, "test") + + assert script_entity_init.call_count == 1 + script_entity_init.reset_mock() + + # Start the script and wait for it to finish + _, object_id = split_entity_id(ENTITY_ID) + await hass.services.async_call(DOMAIN, object_id) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Reload the scripts without any change + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call(script.DOMAIN, SERVICE_RELOAD, blocking=True) + + assert script_entity_init.call_count == 0 + script_entity_init.reset_mock() + + # Start the script and wait for it to start + _, object_id = split_entity_id(ENTITY_ID) + await hass.services.async_call(DOMAIN, object_id) + await hass.async_block_till_done() + assert len(calls) == 2 + + async def test_service_descriptions(hass): """Test that service descriptions are loaded and reloaded correctly.""" # Test 1: has "description" but no "fields" diff --git a/tests/components/script/test_recorder.py b/tests/components/script/test_recorder.py index a023212b82b6f8fa910641503bc9eaa33b6a3efa..ecbe554d9de52e4bb32cd63621f291e4a3d2ad9c 100644 --- a/tests/components/script/test_recorder.py +++ b/tests/components/script/test_recorder.py @@ -27,7 +27,7 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -async def test_exclude_attributes(hass, recorder_mock, calls): +async def test_exclude_attributes(recorder_mock, hass, calls): """Test automation registered attributes to be excluded.""" await hass.async_block_till_done() calls = [] diff --git a/tests/components/select/test_recorder.py b/tests/components/select/test_recorder.py index 083caef34441cea576de5b930bb217d8d5482c3d..0598438029b065fe92bd3c7cee1af0c6c1919f12 100644 --- a/tests/components/select/test_recorder.py +++ b/tests/components/select/test_recorder.py @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test select registered attributes to be excluded.""" await async_setup_component( hass, select.DOMAIN, {select.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index f9c3a7cb301330aae2cb48846b2448ae61ff03eb..224248c2d160a15b55d6021e307781e435c3d6f6 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -23,19 +23,30 @@ from homeassistant.components.climate import ( from homeassistant.components.sensibo.climate import ( ATTR_AC_INTEGRATION, ATTR_GEO_INTEGRATION, + ATTR_HIGH_TEMPERATURE_STATE, + ATTR_HIGH_TEMPERATURE_THRESHOLD, + ATTR_HORIZONTAL_SWING_MODE, ATTR_INDOOR_INTEGRATION, + ATTR_LIGHT, + ATTR_LOW_TEMPERATURE_STATE, + ATTR_LOW_TEMPERATURE_THRESHOLD, ATTR_MINUTES, ATTR_OUTDOOR_INTEGRATION, ATTR_SENSITIVITY, + ATTR_SMART_TYPE, + ATTR_TARGET_TEMPERATURE, SERVICE_ASSUME_STATE, + SERVICE_ENABLE_CLIMATE_REACT, SERVICE_ENABLE_PURE_BOOST, SERVICE_ENABLE_TIMER, + SERVICE_FULL_STATE, _find_valid_target_temp, ) from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_MODE, ATTR_STATE, ATTR_TEMPERATURE, SERVICE_TURN_OFF, @@ -916,3 +927,396 @@ async def test_climate_pure_boost( assert state2.state == "on" assert state3.state == "on" assert state4.state == "s" + + +async def test_climate_climate_react( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate react custom 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") + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_climate_react", + ): + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_PURE_BOOST, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_LOW_TEMPERATURE_THRESHOLD: 0.2, + ATTR_HIGH_TEMPERATURE_THRESHOLD: 30.3, + ATTR_SMART_TYPE: "temperature", + }, + 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_climate_react", + return_value={ + "status": "success", + "result": { + "enabled": True, + "deviceUid": "ABC999111", + "highTemperatureState": { + "on": True, + "targetTemperature": 15, + "temperatureUnit": "C", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + "highTemperatureThreshold": 30.5, + "lowTemperatureState": { + "on": True, + "targetTemperature": 25, + "temperatureUnit": "C", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + "lowTemperatureThreshold": 5.5, + "type": "temperature", + }, + }, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_CLIMATE_REACT, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_LOW_TEMPERATURE_THRESHOLD: 5.5, + ATTR_HIGH_TEMPERATURE_THRESHOLD: 30.5, + ATTR_LOW_TEMPERATURE_STATE: { + "on": True, + "targetTemperature": 25, + "temperatureUnit": "C", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ATTR_HIGH_TEMPERATURE_STATE: { + "on": True, + "targetTemperature": 15, + "temperatureUnit": "C", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ATTR_SMART_TYPE: "temperature", + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_type", "temperature") + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_low_temp_threshold", 5.5) + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_high_temp_threshold", 30.5) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "smart_low_state", + { + "on": True, + "targetTemperature": 25, + "temperatureUnit": "C", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "smart_high_state", + { + "on": True, + "targetTemperature": 15, + "temperatureUnit": "C", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ) + + 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_climate_react") + state2 = hass.states.get("sensor.hallway_climate_react_low_temperature_threshold") + state3 = hass.states.get("sensor.hallway_climate_react_high_temperature_threshold") + state4 = hass.states.get("sensor.hallway_climate_react_type") + assert state1.state == "on" + assert state2.state == "5.5" + assert state3.state == "30.5" + assert state4.state == "temperature" + + +async def test_climate_climate_react_fahrenheit( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate react custom service with fahrenheit.""" + + 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") + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_climate_react", + return_value={ + "status": "success", + "result": { + "enabled": True, + "deviceUid": "ABC999111", + "highTemperatureState": { + "on": True, + "targetTemperature": 65, + "temperatureUnit": "F", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + "highTemperatureThreshold": 77, + "lowTemperatureState": { + "on": True, + "targetTemperature": 85, + "temperatureUnit": "F", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + "lowTemperatureThreshold": 32, + "type": "temperature", + }, + }, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_CLIMATE_REACT, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_LOW_TEMPERATURE_THRESHOLD: 32.0, + ATTR_HIGH_TEMPERATURE_THRESHOLD: 77.0, + ATTR_LOW_TEMPERATURE_STATE: { + "on": True, + "targetTemperature": 85, + "temperatureUnit": "F", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ATTR_HIGH_TEMPERATURE_STATE: { + "on": True, + "targetTemperature": 65, + "temperatureUnit": "F", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ATTR_SMART_TYPE: "temperature", + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_type", "temperature") + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_low_temp_threshold", 0) + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_high_temp_threshold", 25) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "smart_low_state", + { + "on": True, + "targetTemperature": 85, + "temperatureUnit": "F", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "smart_high_state", + { + "on": True, + "targetTemperature": 65, + "temperatureUnit": "F", + "mode": "cool", + "fanLevel": "high", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + }, + ) + + 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_climate_react") + state2 = hass.states.get("sensor.hallway_climate_react_low_temperature_threshold") + state3 = hass.states.get("sensor.hallway_climate_react_high_temperature_threshold") + state4 = hass.states.get("sensor.hallway_climate_react_type") + assert state1.state == "on" + assert state2.state == "0" + assert state3.state == "25" + assert state4.state == "temperature" + + +async def test_climate_full_ac_state( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate Full AC 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.hallway") + assert state_climate.state == "heat" + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_states", + ): + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_FULL_STATE, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_TARGET_TEMPERATURE: 22, + }, + 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_ac_states", + return_value={"result": {"status": "Success"}}, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_FULL_STATE, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_MODE: "cool", + ATTR_TARGET_TEMPERATURE: 22, + ATTR_FAN_MODE: "high", + ATTR_SWING_MODE: "stopped", + ATTR_HORIZONTAL_SWING_MODE: "stopped", + ATTR_LIGHT: "on", + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "hvac_mode", "cool") + monkeypatch.setattr(get_data.parsed["ABC999111"], "device_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "target_temp", 22) + monkeypatch.setattr(get_data.parsed["ABC999111"], "fan_mode", "high") + monkeypatch.setattr(get_data.parsed["ABC999111"], "swing_mode", "stopped") + monkeypatch.setattr( + get_data.parsed["ABC999111"], "horizontal_swing_mode", "stopped" + ) + monkeypatch.setattr(get_data.parsed["ABC999111"], "light_mode", "on") + + 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.state == "cool" + assert state.attributes["temperature"] == 22 diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 426416ae2b99fccd12a4a8861b257fae17ab4a24..676e9f1f73d6e22eb31897a7b393c30a9d8d9000 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_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_sensor( hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, load_int: ConfigEntry, monkeypatch: MonkeyPatch, get_data: SensiboData, @@ -25,9 +26,11 @@ 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") + state4 = hass.states.get("sensor.hallway_climate_react_low_temperature_threshold") assert state1.state == "3000" assert state2.state == "1" assert state3.state == "n" + assert state4.state == "0.0" assert state2.attributes == { "state_class": "measurement", "unit_of_measurement": "µg/m³", @@ -35,6 +38,20 @@ async def test_sensor( "icon": "mdi:air-filter", "friendly_name": "Kitchen PM2.5", } + assert state4.attributes == { + "device_class": "temperature", + "friendly_name": "Hallway Climate React low temperature threshold", + "state_class": "measurement", + "unit_of_measurement": "°C", + "on": True, + "targetTemperature": 21, + "temperatureUnit": "C", + "mode": "heat", + "fanLevel": "low", + "swing": "stopped", + "horizontalSwing": "stopped", + "light": "on", + } monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pm25", 2) diff --git a/tests/components/sensibo/test_switch.py b/tests/components/sensibo/test_switch.py index 2b99fa2e2277259531291a3a484734c5e07b73c7..3ff0e52a0f885c06ca2955505d60c8d65bcca5bd 100644 --- a/tests/components/sensibo/test_switch.py +++ b/tests/components/sensibo/test_switch.py @@ -223,3 +223,113 @@ async def test_switch_command_failure( }, blocking=True, ) + + +async def test_switch_climate_react( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo switch for climate react.""" + + state1 = hass.states.get("switch.hallway_climate_react") + 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_enable_climate_react", + 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["ABC999111"], "smart_on", 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.hallway_climate_react") + 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_enable_climate_react", + 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["ABC999111"], "smart_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_climate_react") + assert state1.state == STATE_OFF + + +async def test_switch_climate_react_no_data( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo switch for climate react.""" + + monkeypatch.setattr(get_data.parsed["ABC999111"], "smart_type", 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() + + state1 = hass.states.get("switch.hallway_climate_react") + assert state1.state == STATE_OFF + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index a9ea9ce0fbecf6c2aae66d9407c627eaa7416268..e168a1c22712f5df18accbbc97222477654911a3 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -11,7 +11,9 @@ from homeassistant.const import ( LENGTH_CENTIMETERS, LENGTH_INCHES, LENGTH_KILOMETERS, + LENGTH_METERS, LENGTH_MILES, + LENGTH_YARD, MASS_GRAMS, MASS_OUNCES, PRESSURE_HPA, @@ -35,7 +37,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.common import mock_restore_cache_with_extra_data @@ -43,8 +45,8 @@ from tests.common import mock_restore_cache_with_extra_data @pytest.mark.parametrize( "unit_system,native_unit,state_unit,native_value,state_value", [ - (IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, TEMP_FAHRENHEIT, 100, 100), - (IMPERIAL_SYSTEM, TEMP_CELSIUS, TEMP_FAHRENHEIT, 38, 100), + (US_CUSTOMARY_SYSTEM, TEMP_FAHRENHEIT, TEMP_FAHRENHEIT, 100, 100), + (US_CUSTOMARY_SYSTEM, TEMP_CELSIUS, TEMP_FAHRENHEIT, 38, 100), (METRIC_SYSTEM, TEMP_FAHRENHEIT, TEMP_CELSIUS, 100, 38), (METRIC_SYSTEM, TEMP_CELSIUS, TEMP_CELSIUS, 38, 38), ], @@ -565,11 +567,11 @@ async def test_custom_unit( SensorDeviceClass.VOLUME, ), ( - VOLUME_FLUID_OUNCE, - VOLUME_LITERS, VOLUME_LITERS, - 78, + VOLUME_FLUID_OUNCE, + VOLUME_FLUID_OUNCE, 2.3, + 77.8, SensorDeviceClass.VOLUME, ), ( @@ -661,3 +663,267 @@ async def test_custom_unit_change( state = hass.states.get(entity0.entity_id) assert float(state.state) == approx(float(native_value)) assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit + + +@pytest.mark.parametrize( + "unit_system, native_unit, automatic_unit, suggested_unit, custom_unit, native_value, automatic_value, suggested_value, custom_value, device_class", + [ + # Distance + ( + US_CUSTOMARY_SYSTEM, + LENGTH_KILOMETERS, + LENGTH_MILES, + LENGTH_METERS, + LENGTH_YARD, + 1000, + 621, + 1000000, + 1093613, + SensorDeviceClass.DISTANCE, + ), + ], +) +async def test_unit_conversion_priority( + hass, + enable_custom_integrations, + unit_system, + native_unit, + automatic_unit, + suggested_unit, + custom_unit, + native_value, + automatic_value, + suggested_value, + custom_value, + device_class, +): + """Test priority of unit conversion.""" + + hass.config.units = unit_system + + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + unique_id="very_unique", + ) + entity0 = platform.ENTITIES["0"] + + platform.ENTITIES["1"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + ) + entity1 = platform.ENTITIES["1"] + + platform.ENTITIES["2"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + suggested_unit_of_measurement=suggested_unit, + unique_id="very_unique_2", + ) + entity2 = platform.ENTITIES["2"] + + platform.ENTITIES["3"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + suggested_unit_of_measurement=suggested_unit, + ) + entity3 = platform.ENTITIES["3"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Registered entity -> Follow automatic unit conversion + state = hass.states.get(entity0.entity_id) + assert float(state.state) == approx(float(automatic_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit + # Assert the automatic unit conversion is stored in the registry + entry = entity_registry.async_get(entity0.entity_id) + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": automatic_unit} + } + + # Unregistered entity -> Follow native unit + state = hass.states.get(entity1.entity_id) + assert float(state.state) == approx(float(native_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit + + # Registered entity with suggested unit + state = hass.states.get(entity2.entity_id) + assert float(state.state) == approx(float(suggested_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit + # Assert the suggested unit is stored in the registry + entry = entity_registry.async_get(entity2.entity_id) + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": suggested_unit} + } + + # Unregistered entity with suggested unit + state = hass.states.get(entity3.entity_id) + assert float(state.state) == approx(float(suggested_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit + + # Set a custom unit, this should have priority over the automatic unit conversion + entity_registry.async_update_entity_options( + entity0.entity_id, "sensor", {"unit_of_measurement": custom_unit} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == approx(float(custom_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit + + entity_registry.async_update_entity_options( + entity2.entity_id, "sensor", {"unit_of_measurement": custom_unit} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity2.entity_id) + assert float(state.state) == approx(float(custom_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit + + +@pytest.mark.parametrize( + "unit_system, native_unit, original_unit, suggested_unit, native_value, original_value, device_class", + [ + # Distance + ( + US_CUSTOMARY_SYSTEM, + LENGTH_KILOMETERS, + LENGTH_YARD, + LENGTH_METERS, + 1000, + 1093613, + SensorDeviceClass.DISTANCE, + ), + ], +) +async def test_unit_conversion_priority_suggested_unit_change( + hass, + enable_custom_integrations, + unit_system, + native_unit, + original_unit, + suggested_unit, + native_value, + original_value, + device_class, +): + """Test priority of unit conversion.""" + + hass.config.units = unit_system + + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + # Pre-register entities + entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") + entity_registry.async_update_entity_options( + entry.entity_id, + "sensor.private", + {"suggested_unit_of_measurement": original_unit}, + ) + entry = entity_registry.async_get_or_create("sensor", "test", "very_unique_2") + entity_registry.async_update_entity_options( + entry.entity_id, + "sensor.private", + {"suggested_unit_of_measurement": original_unit}, + ) + + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + unique_id="very_unique", + ) + entity0 = platform.ENTITIES["0"] + + platform.ENTITIES["1"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + suggested_unit_of_measurement=suggested_unit, + unique_id="very_unique_2", + ) + entity1 = platform.ENTITIES["1"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Registered entity -> Follow automatic unit conversion the first time the entity was seen + state = hass.states.get(entity0.entity_id) + assert float(state.state) == approx(float(original_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == original_unit + + # Registered entity -> Follow suggested unit the first time the entity was seen + state = hass.states.get(entity1.entity_id) + assert float(state.state) == approx(float(original_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == original_unit + + +@pytest.mark.parametrize( + "unit_system, native_unit, original_unit, native_value, original_value, device_class", + [ + # Distance + ( + US_CUSTOMARY_SYSTEM, + LENGTH_KILOMETERS, + LENGTH_MILES, + 1000, + 621, + SensorDeviceClass.DISTANCE, + ), + ], +) +async def test_unit_conversion_priority_legacy_conversion_removed( + hass, + enable_custom_integrations, + unit_system, + native_unit, + original_unit, + native_value, + original_value, + device_class, +): + """Test priority of unit conversion.""" + + hass.config.units = unit_system + + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + # Pre-register entities + entity_registry.async_get_or_create( + "sensor", "test", "very_unique", unit_of_measurement=original_unit + ) + + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + unique_id="very_unique", + ) + entity0 = platform.ENTITIES["0"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == approx(float(original_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == original_unit diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 0a72dcf6fcdf0b6689b9c27307d5cef4d2c345ad..05f8bd40597d188a486d41b982e0d7ce6e8c82d5 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,6 +1,6 @@ """The tests for sensor recorder platform.""" # pylint: disable=protected-access,invalid-name -from datetime import timedelta +from datetime import datetime, timedelta import math from statistics import mean from unittest.mock import patch @@ -9,10 +9,15 @@ import pytest from pytest import approx from homeassistant import loader -from homeassistant.components.recorder import history +from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, history from homeassistant.components.recorder.db_schema import StatisticsMeta -from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMetaData, + process_timestamp_to_utc_isoformat, +) from homeassistant.components.recorder.statistics import ( + async_import_statistics, get_metadata, list_statistic_ids, statistics_during_period, @@ -21,7 +26,7 @@ from homeassistant.components.recorder.util import get_instance, session_scope from homeassistant.const import STATE_UNAVAILABLE from homeassistant.setup import async_setup_component, setup_component import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import ( async_recorder_block_till_done, @@ -398,18 +403,18 @@ def test_compile_hourly_statistics_wrong_unit(hass_recorder, caplog, attributes) @pytest.mark.parametrize( "units, device_class, state_unit, display_unit, statistics_unit, unit_class, factor", [ - (IMPERIAL_SYSTEM, "distance", "m", "m", "m", "distance", 1), - (IMPERIAL_SYSTEM, "distance", "mi", "mi", "mi", "distance", 1), - (IMPERIAL_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), - (IMPERIAL_SYSTEM, "energy", "Wh", "Wh", "Wh", "energy", 1), - (IMPERIAL_SYSTEM, "gas", "m³", "m³", "m³", "volume", 1), - (IMPERIAL_SYSTEM, "gas", "ft³", "ft³", "ft³", "volume", 1), - (IMPERIAL_SYSTEM, "monetary", "EUR", "EUR", "EUR", None, 1), - (IMPERIAL_SYSTEM, "monetary", "SEK", "SEK", "SEK", None, 1), - (IMPERIAL_SYSTEM, "volume", "m³", "m³", "m³", "volume", 1), - (IMPERIAL_SYSTEM, "volume", "ft³", "ft³", "ft³", "volume", 1), - (IMPERIAL_SYSTEM, "weight", "g", "g", "g", "mass", 1), - (IMPERIAL_SYSTEM, "weight", "oz", "oz", "oz", "mass", 1), + (US_CUSTOMARY_SYSTEM, "distance", "m", "m", "m", "distance", 1), + (US_CUSTOMARY_SYSTEM, "distance", "mi", "mi", "mi", "distance", 1), + (US_CUSTOMARY_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), + (US_CUSTOMARY_SYSTEM, "energy", "Wh", "Wh", "Wh", "energy", 1), + (US_CUSTOMARY_SYSTEM, "gas", "m³", "m³", "m³", "volume", 1), + (US_CUSTOMARY_SYSTEM, "gas", "ft³", "ft³", "ft³", "volume", 1), + (US_CUSTOMARY_SYSTEM, "monetary", "EUR", "EUR", "EUR", None, 1), + (US_CUSTOMARY_SYSTEM, "monetary", "SEK", "SEK", "SEK", None, 1), + (US_CUSTOMARY_SYSTEM, "volume", "m³", "m³", "m³", "volume", 1), + (US_CUSTOMARY_SYSTEM, "volume", "ft³", "ft³", "ft³", "volume", 1), + (US_CUSTOMARY_SYSTEM, "weight", "g", "g", "g", "mass", 1), + (US_CUSTOMARY_SYSTEM, "weight", "oz", "oz", "oz", "mass", 1), (METRIC_SYSTEM, "distance", "m", "m", "m", "distance", 1), (METRIC_SYSTEM, "distance", "mi", "mi", "mi", "distance", 1), (METRIC_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), @@ -425,9 +430,9 @@ def test_compile_hourly_statistics_wrong_unit(hass_recorder, caplog, attributes) ], ) async def test_compile_hourly_sum_statistics_amount( + recorder_mock, hass, hass_ws_client, - recorder_mock, caplog, units, state_class, @@ -1907,6 +1912,9 @@ def test_list_statistic_ids_unsupported(hass_recorder, caplog, _attributes): ("battery", "%", "cats", None, 13.050847, -10, 30), ("battery", None, "cats", None, 13.050847, -10, 30), (None, "kW", "Wh", "power", 13.050847, -10, 30), + # Can't downgrade from ft³ to ft3 or from m³ to m3 + (None, "ft³", "ft3", "volume", 13.050847, -10, 30), + (None, "m³", "m3", "volume", 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_units_1( @@ -2187,6 +2195,208 @@ def test_compile_hourly_statistics_changing_units_3( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class, state_unit, state_unit2, unit_class, unit_class2, mean, mean2, min, max", + [ + (None, "RPM", "rpm", None, None, 13.050847, 13.333333, -10, 30), + (None, "rpm", "RPM", None, None, 13.050847, 13.333333, -10, 30), + (None, "ft3", "ft³", None, "volume", 13.050847, 13.333333, -10, 30), + (None, "m3", "m³", None, "volume", 13.050847, 13.333333, -10, 30), + ], +) +def test_compile_hourly_statistics_equivalent_units_1( + hass_recorder, + caplog, + device_class, + state_unit, + state_unit2, + unit_class, + unit_class2, + mean, + mean2, + min, + max, +): + """Test compiling hourly statistics where units change from one hour to the next.""" + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = state_unit2 + four, _states = record_states( + hass, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, zero + timedelta(minutes=10), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + assert "can not be converted to the unit of previously" not in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": state_unit, + "unit_class": unit_class, + }, + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + do_adhoc_statistics(hass, start=zero + timedelta(minutes=10)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": state_unit2, + "unit_class": unit_class2, + }, + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat( + zero + timedelta(minutes=10) + ), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=15)), + "mean": approx(mean2), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + "device_class, state_unit, state_unit2, unit_class, mean, min, max", + [ + (None, "RPM", "rpm", None, 13.333333, -10, 30), + (None, "rpm", "RPM", None, 13.333333, -10, 30), + (None, "ft3", "ft³", None, 13.333333, -10, 30), + (None, "m3", "m³", None, 13.333333, -10, 30), + ], +) +def test_compile_hourly_statistics_equivalent_units_2( + hass_recorder, + caplog, + device_class, + state_unit, + state_unit2, + unit_class, + mean, + min, + max, +): + """Test compiling hourly statistics where units change during an hour.""" + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = state_unit2 + four, _states = record_states( + hass, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + do_adhoc_statistics(hass, start=zero + timedelta(seconds=30 * 5)) + wait_recording_done(hass) + assert "The unit of sensor.test1 is changing" not in caplog.text + assert "and matches the unit of already compiled statistics" not in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": state_unit, + "unit_class": unit_class, + }, + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat( + zero + timedelta(seconds=30 * 5) + ), + "end": process_timestamp_to_utc_isoformat( + zero + timedelta(seconds=30 * 15) + ), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + ] + } + + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class, state_unit, statistic_unit, unit_class, mean1, mean2, min, max", [ @@ -3097,12 +3307,18 @@ def record_states(hass, zero, entity_id, attributes, seq=None): @pytest.mark.parametrize( "units, attributes, unit, unit2, supported_unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), - (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F", "K", "K, °C, °F"), + ( + US_CUSTOMARY_SYSTEM, + TEMPERATURE_SENSOR_ATTRIBUTES, + "°F", + "K", + "K, °C, °F", + ), (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C", "K", "K, °C, °F"), ( - IMPERIAL_SYSTEM, + US_CUSTOMARY_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi", "bar", @@ -3117,10 +3333,17 @@ def record_states(hass, zero, entity_id, attributes, seq=None): ), ], ) -async def test_validate_statistics_unit_change_device_class( - hass, hass_ws_client, recorder_mock, units, attributes, unit, unit2, supported_unit +async def test_validate_unit_change_convertible( + recorder_mock, hass, hass_ws_client, units, attributes, unit, unit2, supported_unit ): - """Test validate_statistics.""" + """Test validate_statistics. + + This tests what happens if a sensor is first recorded in a unit which supports unit + conversion, and the unit is then changed to a unit which can and can not be + converted to the original unit. + + The test also asserts that the sensor's device class is ignored. + """ id = 1 def next_id(): @@ -3190,7 +3413,7 @@ async def test_validate_statistics_unit_change_device_class( await assert_validation_result(client, {}) # Valid state, statistic runs again - empty response - do_adhoc_statistics(hass, start=now) + do_adhoc_statistics(hass, start=now + timedelta(hours=1)) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) @@ -3202,11 +3425,11 @@ async def test_validate_statistics_unit_change_device_class( await assert_validation_result(client, {}) # Valid state, statistic runs again - empty response - do_adhoc_statistics(hass, start=now) + do_adhoc_statistics(hass, start=now + timedelta(hours=2)) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) - # Remove the state - empty response + # Remove the state - expect error about missing state hass.states.async_remove("sensor.test") expected = { "sensor.test": [ @@ -3220,15 +3443,18 @@ async def test_validate_statistics_unit_change_device_class( @pytest.mark.parametrize( - "units, attributes, valid_units", + "units, attributes", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W, kW"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES), ], ) -async def test_validate_statistics_unit_change_device_class_2( - hass, hass_ws_client, recorder_mock, units, attributes, valid_units +async def test_validate_statistics_unit_ignore_device_class( + recorder_mock, hass, hass_ws_client, units, attributes ): - """Test validate_statistics.""" + """Test validate_statistics. + + The test asserts that the sensor's device class is ignored. + """ id = 1 def next_id(): @@ -3273,12 +3499,18 @@ async def test_validate_statistics_unit_change_device_class_2( @pytest.mark.parametrize( "units, attributes, unit, unit2, supported_unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), - (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F", "K", "K, °C, °F"), + ( + US_CUSTOMARY_SYSTEM, + TEMPERATURE_SENSOR_ATTRIBUTES, + "°F", + "K", + "K, °C, °F", + ), (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C", "K", "K, °C, °F"), ( - IMPERIAL_SYSTEM, + US_CUSTOMARY_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi", "bar", @@ -3294,9 +3526,14 @@ async def test_validate_statistics_unit_change_device_class_2( ], ) async def test_validate_statistics_unit_change_no_device_class( - hass, hass_ws_client, recorder_mock, units, attributes, unit, unit2, supported_unit + recorder_mock, hass, hass_ws_client, units, attributes, unit, unit2, supported_unit ): - """Test validate_statistics.""" + """Test validate_statistics. + + This tests what happens if a sensor is first recorded in a unit which supports unit + conversion, and the unit is then changed to a unit which can and can not be + converted to the original unit. + """ id = 1 attributes = dict(attributes) attributes.pop("device_class") @@ -3324,14 +3561,14 @@ async def test_validate_statistics_unit_change_no_device_class( # No statistics, no state - empty response await assert_validation_result(client, {}) - # No statistics, unit in state matching device class - empty response + # No statistics, sensor state set - empty response hass.states.async_set( "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}} ) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) - # No statistics, unit in state not matching device class - empty response + # No statistics, sensor state set to an incompatible unit - empty response hass.states.async_set( "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} ) @@ -3368,7 +3605,7 @@ async def test_validate_statistics_unit_change_no_device_class( await assert_validation_result(client, {}) # Valid state, statistic runs again - empty response - do_adhoc_statistics(hass, start=now) + do_adhoc_statistics(hass, start=now + timedelta(hours=1)) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) @@ -3380,11 +3617,11 @@ async def test_validate_statistics_unit_change_no_device_class( await assert_validation_result(client, {}) # Valid state, statistic runs again - empty response - do_adhoc_statistics(hass, start=now) + do_adhoc_statistics(hass, start=now + timedelta(hours=2)) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) - # Remove the state - empty response + # Remove the state - expect error about missing state hass.states.async_remove("sensor.test") expected = { "sensor.test": [ @@ -3400,11 +3637,11 @@ async def test_validate_statistics_unit_change_no_device_class( @pytest.mark.parametrize( "units, attributes, unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), ], ) async def test_validate_statistics_unsupported_state_class( - hass, hass_ws_client, recorder_mock, units, attributes, unit + recorder_mock, hass, hass_ws_client, units, attributes, unit ): """Test validate_statistics.""" id = 1 @@ -3464,11 +3701,11 @@ async def test_validate_statistics_unsupported_state_class( @pytest.mark.parametrize( "units, attributes, unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), ], ) async def test_validate_statistics_sensor_no_longer_recorded( - hass, hass_ws_client, recorder_mock, units, attributes, unit + recorder_mock, hass, hass_ws_client, units, attributes, unit ): """Test validate_statistics.""" id = 1 @@ -3525,11 +3762,11 @@ async def test_validate_statistics_sensor_no_longer_recorded( @pytest.mark.parametrize( "units, attributes, unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), ], ) async def test_validate_statistics_sensor_not_recorded( - hass, hass_ws_client, recorder_mock, units, attributes, unit + recorder_mock, hass, hass_ws_client, units, attributes, unit ): """Test validate_statistics.""" id = 1 @@ -3583,11 +3820,11 @@ async def test_validate_statistics_sensor_not_recorded( @pytest.mark.parametrize( "units, attributes, unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), ], ) async def test_validate_statistics_sensor_removed( - hass, hass_ws_client, recorder_mock, units, attributes, unit + recorder_mock, hass, hass_ws_client, units, attributes, unit ): """Test validate_statistics.""" id = 1 @@ -3639,11 +3876,14 @@ async def test_validate_statistics_sensor_removed( @pytest.mark.parametrize( - "attributes", - [BATTERY_SENSOR_ATTRIBUTES, NONE_SENSOR_ATTRIBUTES], + "attributes, unit1, unit2", + [ + (BATTERY_SENSOR_ATTRIBUTES, "%", "dogs"), + (NONE_SENSOR_ATTRIBUTES, None, "dogs"), + ], ) async def test_validate_statistics_unit_change_no_conversion( - hass, recorder_mock, hass_ws_client, attributes + recorder_mock, hass, hass_ws_client, attributes, unit1, unit2 ): """Test validate_statistics.""" id = 1 @@ -3682,12 +3922,14 @@ async def test_validate_statistics_unit_change_no_conversion( await assert_validation_result(client, {}) # No statistics, original unit - empty response - hass.states.async_set("sensor.test", 10, attributes=attributes) + hass.states.async_set( + "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit1}} + ) await assert_validation_result(client, {}) # No statistics, changed unit - empty response hass.states.async_set( - "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": unit2}} ) await assert_validation_result(client, {}) @@ -3697,32 +3939,34 @@ async def test_validate_statistics_unit_change_no_conversion( await async_recorder_block_till_done(hass) await assert_statistic_ids([]) - # No statistics, changed unit - empty response + # No statistics, original unit - empty response hass.states.async_set( - "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": unit1}} ) await assert_validation_result(client, {}) - # Run statistics one hour later, only the "dogs" state will be considered + # Run statistics one hour later, only the state with unit1 will be considered await async_recorder_block_till_done(hass) do_adhoc_statistics(hass, start=now + timedelta(hours=1)) await async_recorder_block_till_done(hass) await assert_statistic_ids( - [{"statistic_id": "sensor.test", "unit_of_measurement": "dogs"}] + [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] ) await assert_validation_result(client, {}) - # Change back to original unit - expect error - hass.states.async_set("sensor.test", 13, attributes=attributes) + # Change unit - expect error + hass.states.async_set( + "sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit2}} + ) await async_recorder_block_till_done(hass) expected = { "sensor.test": [ { "data": { - "metadata_unit": "dogs", - "state_unit": attributes.get("unit_of_measurement"), + "metadata_unit": unit1, + "state_unit": unit2, "statistic_id": "sensor.test", - "supported_unit": "dogs", + "supported_unit": unit1, }, "type": "units_changed", } @@ -3730,16 +3974,16 @@ async def test_validate_statistics_unit_change_no_conversion( } await assert_validation_result(client, expected) - # Changed unit - empty response + # Original unit - empty response hass.states.async_set( - "sensor.test", 14, attributes={**attributes, **{"unit_of_measurement": "dogs"}} + "sensor.test", 14, attributes={**attributes, **{"unit_of_measurement": unit1}} ) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) # Valid state, statistic runs again - empty response await async_recorder_block_till_done(hass) - do_adhoc_statistics(hass, start=now) + do_adhoc_statistics(hass, start=now + timedelta(hours=2)) await async_recorder_block_till_done(hass) await assert_validation_result(client, {}) @@ -3756,6 +4000,226 @@ async def test_validate_statistics_unit_change_no_conversion( await assert_validation_result(client, expected) +@pytest.mark.parametrize( + "attributes, unit1, unit2", + [ + (NONE_SENSOR_ATTRIBUTES, "m3", "m³"), + (NONE_SENSOR_ATTRIBUTES, "rpm", "RPM"), + (NONE_SENSOR_ATTRIBUTES, "RPM", "rpm"), + ], +) +async def test_validate_statistics_unit_change_equivalent_units( + recorder_mock, hass, hass_ws_client, attributes, unit1, unit2 +): + """Test validate_statistics. + + This tests no validation issue is created when a sensor's unit changes to an + equivalent unit. + """ + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + async def assert_statistic_ids(expected_result): + with session_scope(hass=hass) as session: + db_states = list(session.query(StatisticsMeta)) + assert len(db_states) == len(expected_result) + for i in range(len(db_states)): + assert db_states[i].statistic_id == expected_result[i]["statistic_id"] + assert ( + db_states[i].unit_of_measurement + == expected_result[i]["unit_of_measurement"] + ) + + now = dt_util.utcnow() + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, original unit - empty response + hass.states.async_set( + "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit1}} + ) + await assert_validation_result(client, {}) + + # Run statistics + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now) + await async_recorder_block_till_done(hass) + await assert_statistic_ids( + [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] + ) + + # Units changed to an equivalent unit - empty response + hass.states.async_set( + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": unit2}} + ) + await assert_validation_result(client, {}) + + # Run statistics one hour later, metadata will be updated + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now + timedelta(hours=1)) + await async_recorder_block_till_done(hass) + await assert_statistic_ids( + [{"statistic_id": "sensor.test", "unit_of_measurement": unit2}] + ) + await assert_validation_result(client, {}) + + +@pytest.mark.parametrize( + "attributes, unit1, unit2, supported_unit", + [ + (NONE_SENSOR_ATTRIBUTES, "m³", "m3", "L, fl. oz., ft³, gal, mL, m³"), + ], +) +async def test_validate_statistics_unit_change_equivalent_units_2( + recorder_mock, hass, hass_ws_client, attributes, unit1, unit2, supported_unit +): + """Test validate_statistics. + + This tests a validation issue is created when a sensor's unit changes to an + equivalent unit which is not known to the unit converters. + """ + + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + async def assert_statistic_ids(expected_result): + with session_scope(hass=hass) as session: + db_states = list(session.query(StatisticsMeta)) + assert len(db_states) == len(expected_result) + for i in range(len(db_states)): + assert db_states[i].statistic_id == expected_result[i]["statistic_id"] + assert ( + db_states[i].unit_of_measurement + == expected_result[i]["unit_of_measurement"] + ) + + now = dt_util.utcnow() + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(client, {}) + + # No statistics, original unit - empty response + hass.states.async_set( + "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit1}} + ) + await assert_validation_result(client, {}) + + # Run statistics + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now) + await async_recorder_block_till_done(hass) + await assert_statistic_ids( + [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] + ) + + # Units changed to an equivalent unit which is not known by the unit converters + hass.states.async_set( + "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": unit2}} + ) + expected = { + "sensor.test": [ + { + "data": { + "metadata_unit": unit1, + "state_unit": unit2, + "statistic_id": "sensor.test", + "supported_unit": supported_unit, + }, + "type": "units_changed", + } + ], + } + await assert_validation_result(client, expected) + + # Run statistics one hour later, metadata will not be updated + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now + timedelta(hours=1)) + await async_recorder_block_till_done(hass) + await assert_statistic_ids( + [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] + ) + await assert_validation_result(client, expected) + + +async def test_validate_statistics_other_domain(recorder_mock, hass, hass_ws_client): + """Test sensor does not raise issues for statistics for other domains.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_validation_result(client, expected_result): + await client.send_json( + {"id": next_id(), "type": "recorder/validate_statistics"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expected_result + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + # Create statistics for another domain + metadata: StatisticMetaData = { + "has_mean": True, + "has_sum": True, + "name": None, + "source": RECORDER_DOMAIN, + "statistic_id": "number.test", + "unit_of_measurement": None, + } + statistics: StatisticData = { + "last_reset": None, + "max": None, + "mean": None, + "min": None, + "start": datetime(2020, 10, 6, tzinfo=dt_util.UTC), + "state": None, + "sum": None, + } + async_import_statistics(hass, metadata, (statistics,)) + await async_recorder_block_till_done(hass) + + # We should not get complains about the missing number entity + await assert_validation_result(client, {}) + + def record_meter_states(hass, zero, entity_id, _attributes, seq): """Record some test states. diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 3c502c81deb4a90225f02f18d43f7b074d1d5296..a3c571d71773d88487bf1f610a7df394e110596f 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -1 +1,28 @@ """Tests for the Shelly integration.""" +from homeassistant.components.shelly.const import CONF_SLEEP_PERIOD, DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_MAC = "123456789ABC" + + +async def init_integration( + hass: HomeAssistant, gen: int, model="SHSW-25", sleep_period=0 +) -> MockConfigEntry: + """Set up the Shelly integration in Home Assistant.""" + data = { + CONF_HOST: "192.168.1.37", + CONF_SLEEP_PERIOD: sleep_period, + "model": model, + "gen": gen, + } + + entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 49e86e118e3c29109c8ae7870ae40d70857f42b4..cd23cc240c53633380ca4b6c6d7aef86fc7d3cd6 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -1,38 +1,24 @@ """Test configuration for Shelly.""" from unittest.mock import AsyncMock, Mock, patch +from aioshelly.block_device import BlockDevice +from aioshelly.rpc_device import RpcDevice import pytest -from homeassistant.components.shelly import ( - BlockDeviceWrapper, - RpcDeviceWrapper, - RpcPollingWrapper, - ShellyDeviceRestWrapper, -) from homeassistant.components.shelly.const import ( - BLOCK, - DATA_CONFIG_ENTRY, - DOMAIN, EVENT_SHELLY_CLICK, - REST, REST_SENSORS_UPDATE_INTERVAL, - RPC, - RPC_POLL, ) -from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_capture_events, - async_mock_service, - mock_device_registry, -) +from . import MOCK_MAC + +from tests.common import async_capture_events, async_mock_service, mock_device_registry MOCK_SETTINGS = { "name": "Test name", "mode": "relay", "device": { - "mac": "test-mac", + "mac": MOCK_MAC, "hostname": "test-host", "type": "SHSW-25", "num_outputs": 2, @@ -43,6 +29,36 @@ MOCK_SETTINGS = { "rollers": [{"positioning": True}], } + +def mock_light_set_state( + turn="on", + mode="color", + red=45, + green=55, + blue=65, + white=70, + gain=19, + temp=4050, + brightness=50, + effect=0, + transition=0, +): + """Mock light block set_state.""" + return { + "ison": turn == "on", + "mode": mode, + "red": red, + "green": green, + "blue": blue, + "white": white, + "gain": gain, + "temp": temp, + "brightness": brightness, + "effect": effect, + "transition": transition, + } + + MOCK_BLOCKS = [ Mock( sensor_ids={"inputEvent": "S", "inputEventCnt": 2}, @@ -61,6 +77,15 @@ MOCK_BLOCKS = [ } ), ), + Mock( + sensor_ids={}, + channel="0", + output=mock_light_set_state()["ison"], + colorTemp=mock_light_set_state()["temp"], + **mock_light_set_state(), + type="light", + set_state=AsyncMock(side_effect=mock_light_set_state), + ), ] MOCK_CONFIG = { @@ -74,7 +99,7 @@ MOCK_CONFIG = { } MOCK_SHELLY_COAP = { - "mac": "test-mac", + "mac": MOCK_MAC, "auth": False, "fw": "20201124-092854/v1.9.0@57ac4ad8", "num_outputs": 2, @@ -83,7 +108,7 @@ MOCK_SHELLY_COAP = { MOCK_SHELLY_RPC = { "name": "Test Gen2", "id": "shellyplus2pm-123456789abc", - "mac": "123456789ABC", + "mac": MOCK_MAC, "model": "SNSW-002P16EU", "gen": 2, "fw_id": "20220830-130540/0.11.0-gfa1bc37", @@ -121,7 +146,20 @@ MOCK_STATUS_RPC = { @pytest.fixture(autouse=True) def mock_coap(): """Mock out coap.""" - with patch("homeassistant.components.shelly.utils.get_coap_context"): + with patch( + "homeassistant.components.shelly.utils.COAP", + return_value=Mock( + initialize=AsyncMock(), + close=Mock(), + ), + ): + yield + + +@pytest.fixture(autouse=True) +def mock_ws_server(): + """Mock out ws_server.""" + with patch("homeassistant.components.shelly.utils.get_ws_context"): yield @@ -144,80 +182,47 @@ def events(hass): @pytest.fixture -async def coap_wrapper(hass): - """Setups a coap wrapper with mocked device.""" - await async_setup_component(hass, "shelly", {}) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={"sleep_period": 0, "model": "SHSW-25", "host": "1.2.3.4"}, - unique_id="12345678", - ) - config_entry.add_to_hass(hass) - - device = Mock( - blocks=MOCK_BLOCKS, - settings=MOCK_SETTINGS, - shelly=MOCK_SHELLY_COAP, - status=MOCK_STATUS_COAP, - firmware_version="some fw string", - update=AsyncMock(), - update_status=AsyncMock(), - trigger_ota_update=AsyncMock(), - trigger_reboot=AsyncMock(), - initialized=True, - ) - - hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - REST - ] = ShellyDeviceRestWrapper(hass, device, config_entry) - - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - BLOCK - ] = BlockDeviceWrapper(hass, config_entry, device) - - wrapper.async_setup() - - return wrapper +async def mock_block_device(): + """Mock block (Gen1, CoAP) device.""" + with patch("aioshelly.block_device.BlockDevice.create") as block_device_mock: + + def update(): + block_device_mock.return_value.subscribe_updates.call_args[0][0]({}) + + device = Mock( + spec=BlockDevice, + blocks=MOCK_BLOCKS, + settings=MOCK_SETTINGS, + shelly=MOCK_SHELLY_COAP, + status=MOCK_STATUS_COAP, + firmware_version="some fw string", + initialized=True, + ) + block_device_mock.return_value = device + block_device_mock.return_value.mock_update = Mock(side_effect=update) + + yield block_device_mock.return_value @pytest.fixture -async def rpc_wrapper(hass): - """Setups a rpc wrapper with mocked device.""" - await async_setup_component(hass, "shelly", {}) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={"sleep_period": 0, "model": "SNSW-001P16EU", "gen": 2, "host": "1.2.3.4"}, - unique_id="12345678", - ) - config_entry.add_to_hass(hass) - - device = Mock( - call_rpc=AsyncMock(), - config=MOCK_CONFIG, - event={}, - shelly=MOCK_SHELLY_RPC, - status=MOCK_STATUS_RPC, - firmware_version="some fw string", - update=AsyncMock(), - trigger_ota_update=AsyncMock(), - trigger_reboot=AsyncMock(), - initialized=True, - shutdown=AsyncMock(), - ) - - hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - RPC_POLL - ] = RpcPollingWrapper(hass, config_entry, device) - - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - RPC - ] = RpcDeviceWrapper(hass, config_entry, device) - wrapper.async_setup() - - return wrapper +async def mock_rpc_device(): + """Mock rpc (Gen2, Websocket) device.""" + with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock: + + def update(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]({}) + + device = Mock( + spec=RpcDevice, + config=MOCK_CONFIG, + event={}, + shelly=MOCK_SHELLY_RPC, + status=MOCK_STATUS_RPC, + firmware_version="some fw string", + initialized=True, + ) + + rpc_device_mock.return_value = device + rpc_device_mock.return_value.mock_update = Mock(side_effect=update) + + yield rpc_device_mock.return_value diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 8bbae677fb63597b5aa4e472e1476af1d42c3e79..bd20be7c6457787da26e3ab0c75139f25a1086d3 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -1,33 +1,17 @@ """Tests for Shelly button platform.""" from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.shelly.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import async_get +from . import init_integration -async def test_block_button(hass: HomeAssistant, coap_wrapper): - """Test block device reboot button.""" - assert coap_wrapper - entity_registry = async_get(hass) - entity_registry.async_get_or_create( - BUTTON_DOMAIN, - DOMAIN, - "test_name_reboot", - suggested_object_id="test_name_reboot", - disabled_by=None, - ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, BUTTON_DOMAIN) - ) - await hass.async_block_till_done() +async def test_block_button(hass: HomeAssistant, mock_block_device): + """Test block device reboot button.""" + await init_integration(hass, 1) # reboot button - state = hass.states.get("button.test_name_reboot") - - assert state - assert state.state == STATE_UNKNOWN + assert hass.states.get("button.test_name_reboot").state == STATE_UNKNOWN await hass.services.async_call( BUTTON_DOMAIN, @@ -35,33 +19,15 @@ async def test_block_button(hass: HomeAssistant, coap_wrapper): {ATTR_ENTITY_ID: "button.test_name_reboot"}, blocking=True, ) - await hass.async_block_till_done() - assert coap_wrapper.device.trigger_reboot.call_count == 1 + assert mock_block_device.trigger_reboot.call_count == 1 -async def test_rpc_button(hass: HomeAssistant, rpc_wrapper): +async def test_rpc_button(hass: HomeAssistant, mock_rpc_device): """Test rpc device OTA button.""" - assert rpc_wrapper - - entity_registry = async_get(hass) - entity_registry.async_get_or_create( - BUTTON_DOMAIN, - DOMAIN, - "test_name_reboot", - suggested_object_id="test_name_reboot", - disabled_by=None, - ) - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, BUTTON_DOMAIN) - ) - await hass.async_block_till_done() + await init_integration(hass, 2) # reboot button - state = hass.states.get("button.test_name_reboot") - - assert state - assert state.state == STATE_UNKNOWN + assert hass.states.get("button.test_name_reboot").state == STATE_UNKNOWN await hass.services.async_call( BUTTON_DOMAIN, @@ -69,5 +35,4 @@ async def test_rpc_button(hass: HomeAssistant, rpc_wrapper): {ATTR_ENTITY_ID: "button.test_name_reboot"}, blocking=True, ) - await hass.async_block_till_done() - assert rpc_wrapper.device.trigger_reboot.call_count == 1 + assert mock_rpc_device.trigger_reboot.call_count == 1 diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index a761fe7836e397fc28f5d44b04859cddd784263c..ad28ffbd4f0359b5ca5144b19ad3d60fe6bbdac6 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -1,10 +1,11 @@ """Test the Shelly config flow.""" -import asyncio -from http import HTTPStatus from unittest.mock import AsyncMock, Mock, patch -import aiohttp -import aioshelly +from aioshelly.exceptions import ( + DeviceConnectionError, + FirmwareUnsupported, + InvalidAuthError, +) import pytest from homeassistant import config_entries, data_entry_flow @@ -207,7 +208,7 @@ async def test_form_auth(hass, test_data): @pytest.mark.parametrize( - "error", [(asyncio.TimeoutError, "cannot_connect"), (ValueError, "unknown")] + "error", [(DeviceConnectionError, "cannot_connect"), (ValueError, "unknown")] ) async def test_form_errors_get_info(hass, error): """Test we handle errors.""" @@ -324,7 +325,7 @@ async def test_form_missing_model_key_zeroconf(hass, caplog): @pytest.mark.parametrize( - "error", [(asyncio.TimeoutError, "cannot_connect"), (ValueError, "unknown")] + "error", [(DeviceConnectionError, "cannot_connect"), (ValueError, "unknown")] ) async def test_form_errors_test_connection(hass, error): """Test we handle errors.""" @@ -431,10 +432,7 @@ async def test_form_firmware_unsupported(hass): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "aioshelly.common.get_info", - side_effect=aioshelly.exceptions.FirmwareUnsupported, - ): + with patch("aioshelly.common.get_info", side_effect=FirmwareUnsupported): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "1.1.1.1"}, @@ -447,15 +445,8 @@ async def test_form_firmware_unsupported(hass): @pytest.mark.parametrize( "error", [ - ( - aiohttp.ClientResponseError(Mock(), (), status=HTTPStatus.BAD_REQUEST), - "cannot_connect", - ), - ( - aiohttp.ClientResponseError(Mock(), (), status=HTTPStatus.UNAUTHORIZED), - "invalid_auth", - ), - (asyncio.TimeoutError, "cannot_connect"), + (InvalidAuthError, "invalid_auth"), + (DeviceConnectionError, "cannot_connect"), (ValueError, "unknown"), ], ) @@ -490,15 +481,8 @@ async def test_form_auth_errors_test_connection_gen1(hass, error): @pytest.mark.parametrize( "error", [ - ( - aioshelly.exceptions.JSONRPCError(code=400), - "cannot_connect", - ), - ( - aioshelly.exceptions.InvalidAuthError(code=401), - "invalid_auth", - ), - (asyncio.TimeoutError, "cannot_connect"), + (DeviceConnectionError, "cannot_connect"), + (InvalidAuthError, "invalid_auth"), (ValueError, "unknown"), ], ) @@ -647,20 +631,8 @@ async def test_zeroconf_sleeping_device(hass): assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - "error", - [ - ( - aiohttp.ClientResponseError(Mock(), (), status=HTTPStatus.BAD_REQUEST), - "cannot_connect", - ), - (asyncio.TimeoutError, "cannot_connect"), - ], -) -async def test_zeroconf_sleeping_device_error(hass, error): +async def test_zeroconf_sleeping_device_error(hass): """Test sleeping device configuration via zeroconf with error.""" - exc = error - with patch( "aioshelly.common.get_info", return_value={ @@ -671,7 +643,7 @@ async def test_zeroconf_sleeping_device_error(hass, error): }, ), patch( "aioshelly.block_device.BlockDevice.create", - new=AsyncMock(side_effect=exc), + new=AsyncMock(side_effect=DeviceConnectionError), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -708,10 +680,7 @@ async def test_zeroconf_already_configured(hass): async def test_zeroconf_firmware_unsupported(hass): """Test we abort if device firmware is unsupported.""" - with patch( - "aioshelly.common.get_info", - side_effect=aioshelly.exceptions.FirmwareUnsupported, - ): + with patch("aioshelly.common.get_info", side_effect=FirmwareUnsupported): result = await hass.config_entries.flow.async_init( DOMAIN, data=DISCOVERY_INFO, @@ -724,7 +693,7 @@ async def test_zeroconf_firmware_unsupported(hass): async def test_zeroconf_cannot_connect(hass): """Test we get the form.""" - with patch("aioshelly.common.get_info", side_effect=asyncio.TimeoutError): + with patch("aioshelly.common.get_info", side_effect=DeviceConnectionError): result = await hass.config_entries.flow.async_init( DOMAIN, data=DISCOVERY_INFO, @@ -840,21 +809,13 @@ async def test_reauth_successful(hass, test_data): @pytest.mark.parametrize( "test_data", [ - ( - 1, - {"username": "test user", "password": "test1 password"}, - aioshelly.exceptions.InvalidAuthError(code=HTTPStatus.UNAUTHORIZED.value), - ), - ( - 2, - {"password": "test2 password"}, - aiohttp.ClientResponseError(Mock(), (), status=HTTPStatus.UNAUTHORIZED), - ), + (1, {"username": "test user", "password": "test1 password"}), + (2, {"password": "test2 password"}), ], ) async def test_reauth_unsuccessful(hass, test_data): """Test reauthentication flow failed.""" - gen, user_input, exc = test_data + gen, user_input = test_data entry = MockConfigEntry( domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} ) @@ -865,9 +826,10 @@ async def test_reauth_unsuccessful(hass, test_data): return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True, "gen": gen}, ), patch( "aioshelly.block_device.BlockDevice.create", - new=AsyncMock(side_effect=exc), + new=AsyncMock(side_effect=InvalidAuthError), ), patch( - "aioshelly.rpc_device.RpcDevice.create", new=AsyncMock(side_effect=exc) + "aioshelly.rpc_device.RpcDevice.create", + new=AsyncMock(side_effect=InvalidAuthError), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -889,11 +851,7 @@ async def test_reauth_unsuccessful(hass, test_data): @pytest.mark.parametrize( "error", - [ - asyncio.TimeoutError, - aiohttp.ClientError, - aioshelly.exceptions.FirmwareUnsupported, - ], + [DeviceConnectionError, FirmwareUnsupported], ) async def test_reauth_get_info_error(hass, error): """Test reauthentication flow failed with error in get_info().""" diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index ab8c9a9a876b8e8143826b4d99df70a690cd81dd..51fef7dc030a9d1adb79a59d8e1142099b144707 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -1,4 +1,4 @@ -"""The scene tests for the myq platform.""" +"""Tests for Shelly cover platform.""" from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, @@ -13,20 +13,16 @@ from homeassistant.components.cover import ( STATE_OPENING, ) from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.helpers.entity_component import async_update_entity + +from . import init_integration ROLLER_BLOCK_ID = 1 -async def test_block_device_services(hass, coap_wrapper, monkeypatch): +async def test_block_device_services(hass, mock_block_device, monkeypatch): """Test block device cover services.""" - assert coap_wrapper - - monkeypatch.setitem(coap_wrapper.device.settings, "mode", "roller") - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() + monkeypatch.setitem(mock_block_device.settings, "mode", "roller") + await init_integration(hass, 1) await hass.services.async_call( COVER_DOMAIN, @@ -62,46 +58,28 @@ async def test_block_device_services(hass, coap_wrapper, monkeypatch): assert hass.states.get("cover.test_name").state == STATE_CLOSED -async def test_block_device_update(hass, coap_wrapper, monkeypatch): +async def test_block_device_update(hass, mock_block_device, monkeypatch): """Test block device update.""" - assert coap_wrapper - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() + monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "rollerPos", 0) + await init_integration(hass, 1) - monkeypatch.setattr(coap_wrapper.device.blocks[ROLLER_BLOCK_ID], "rollerPos", 0) - await async_update_entity(hass, "cover.test_name") - await hass.async_block_till_done() assert hass.states.get("cover.test_name").state == STATE_CLOSED - monkeypatch.setattr(coap_wrapper.device.blocks[ROLLER_BLOCK_ID], "rollerPos", 100) - await async_update_entity(hass, "cover.test_name") - await hass.async_block_till_done() + monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "rollerPos", 100) + mock_block_device.mock_update() assert hass.states.get("cover.test_name").state == STATE_OPEN -async def test_block_device_no_roller_blocks(hass, coap_wrapper, monkeypatch): +async def test_block_device_no_roller_blocks(hass, mock_block_device, monkeypatch): """Test block device without roller blocks.""" - assert coap_wrapper - - monkeypatch.setattr(coap_wrapper.device.blocks[ROLLER_BLOCK_ID], "type", None) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() + monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "type", None) + await init_integration(hass, 1) assert hass.states.get("cover.test_name") is None -async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch): +async def test_rpc_device_services(hass, mock_rpc_device, monkeypatch): """Test RPC device cover services.""" - assert rpc_wrapper - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() + await init_integration(hass, 2) await hass.services.async_call( COVER_DOMAIN, @@ -112,81 +90,57 @@ async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch): state = hass.states.get("cover.test_cover_0") assert state.attributes[ATTR_CURRENT_POSITION] == 50 - monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "opening") + monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "opening") await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: "cover.test_cover_0"}, blocking=True, ) - rpc_wrapper.async_set_updated_data("") + mock_rpc_device.mock_update() assert hass.states.get("cover.test_cover_0").state == STATE_OPENING - monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "closing") + monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "closing") await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: "cover.test_cover_0"}, blocking=True, ) - rpc_wrapper.async_set_updated_data("") + mock_rpc_device.mock_update() assert hass.states.get("cover.test_cover_0").state == STATE_CLOSING - monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "closed") + monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "closed") await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: "cover.test_cover_0"}, blocking=True, ) - rpc_wrapper.async_set_updated_data("") + mock_rpc_device.mock_update() assert hass.states.get("cover.test_cover_0").state == STATE_CLOSED -async def test_rpc_device_no_cover_keys(hass, rpc_wrapper, monkeypatch): +async def test_rpc_device_no_cover_keys(hass, mock_rpc_device, monkeypatch): """Test RPC device without cover keys.""" - assert rpc_wrapper - - monkeypatch.delitem(rpc_wrapper.device.status, "cover:0") - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + await init_integration(hass, 2) assert hass.states.get("cover.test_cover_0") is None -async def test_rpc_device_update(hass, rpc_wrapper, monkeypatch): +async def test_rpc_device_update(hass, mock_rpc_device, monkeypatch): """Test RPC device update.""" - assert rpc_wrapper - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() - - monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "closed") - await async_update_entity(hass, "cover.test_cover_0") - await hass.async_block_till_done() + monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "closed") + await init_integration(hass, 2) assert hass.states.get("cover.test_cover_0").state == STATE_CLOSED - monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "open") - await async_update_entity(hass, "cover.test_cover_0") - await hass.async_block_till_done() + monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "open") + mock_rpc_device.mock_update() assert hass.states.get("cover.test_cover_0").state == STATE_OPEN -async def test_rpc_device_no_position_control(hass, rpc_wrapper, monkeypatch): +async def test_rpc_device_no_position_control(hass, mock_rpc_device, monkeypatch): """Test RPC device with no position control.""" - assert rpc_wrapper - - monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "pos_control", False) - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, COVER_DOMAIN) - ) - await hass.async_block_till_done() - - await async_update_entity(hass, "cover.test_cover_0") - await hass.async_block_till_done() + monkeypatch.setitem(mock_rpc_device.status["cover:0"], "pos_control", False) + await init_integration(hass, 2) assert hass.states.get("cover.test_cover_0").state == STATE_OPEN diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index b638032e96ef6eb282257148ab6878df60a52f84..d5881696bf6ddfb856928b59ef07809a07b86764 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -1,6 +1,4 @@ """The tests for Shelly device triggers.""" -from unittest.mock import AsyncMock, Mock - import pytest from homeassistant.components import automation @@ -8,20 +6,23 @@ from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) -from homeassistant.components.shelly import BlockDeviceWrapper from homeassistant.components.shelly.const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, - BLOCK, CONF_SUBTYPE, - DATA_CONFIG_ENTRY, DOMAIN, EVENT_SHELLY_CLICK, ) from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.helpers import device_registry +from homeassistant.helpers.device_registry import ( + async_entries_for_config_entry, + async_get as async_get_dev_reg, +) from homeassistant.setup import async_setup_component +from . import init_integration + from tests.common import ( MockConfigEntry, assert_lists_same, @@ -39,48 +40,52 @@ from tests.common import ( ], ) async def test_get_triggers_block_device( - hass, coap_wrapper, monkeypatch, button_type, is_valid + hass, mock_block_device, monkeypatch, button_type, is_valid ): """Test we get the expected triggers from a shelly block device.""" - assert coap_wrapper - monkeypatch.setitem( - coap_wrapper.device.settings, + mock_block_device.settings, "relays", [ {"btn_type": button_type}, {"btn_type": "toggle"}, ], ) + entry = await init_integration(hass, 1) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] expected_triggers = [] if is_valid: expected_triggers = [ { CONF_PLATFORM: "device", - CONF_DEVICE_ID: coap_wrapper.device_id, + CONF_DEVICE_ID: device.id, CONF_DOMAIN: DOMAIN, - CONF_TYPE: type, + CONF_TYPE: type_, CONF_SUBTYPE: "button1", "metadata": {}, } - for type in ["single", "long"] + for type_ in ["single", "long"] ] triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, coap_wrapper.device_id + hass, DeviceAutomationType.TRIGGER, device.id ) - + triggers = [value for value in triggers if value["domain"] == DOMAIN] assert_lists_same(triggers, expected_triggers) -async def test_get_triggers_rpc_device(hass, rpc_wrapper): +async def test_get_triggers_rpc_device(hass, mock_rpc_device): """Test we get the expected triggers from a shelly RPC device.""" - assert rpc_wrapper + entry = await init_integration(hass, 2) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + expected_triggers = [ { CONF_PLATFORM: "device", - CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DEVICE_ID: device.id, CONF_DOMAIN: DOMAIN, CONF_TYPE: type, CONF_SUBTYPE: "button1", @@ -90,43 +95,22 @@ async def test_get_triggers_rpc_device(hass, rpc_wrapper): ] triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, rpc_wrapper.device_id + hass, DeviceAutomationType.TRIGGER, device.id ) - + triggers = [value for value in triggers if value["domain"] == DOMAIN] assert_lists_same(triggers, expected_triggers) -async def test_get_triggers_button(hass): +async def test_get_triggers_button(hass, mock_block_device): """Test we get the expected triggers from a shelly button.""" - await async_setup_component(hass, "shelly", {}) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={"sleep_period": 43200, "model": "SHBTN-1", "host": "1.2.3.4"}, - unique_id="12345678", - ) - config_entry.add_to_hass(hass) - - device = Mock( - blocks=None, - settings=None, - shelly=None, - update=AsyncMock(), - initialized=False, - ) - - hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} - coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - BLOCK - ] = BlockDeviceWrapper(hass, config_entry, device) - - coap_wrapper.async_setup() + entry = await init_integration(hass, 1, model="SHBTN-1") + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] expected_triggers = [ { CONF_PLATFORM: "device", - CONF_DEVICE_ID: coap_wrapper.device_id, + CONF_DEVICE_ID: device.id, CONF_DOMAIN: DOMAIN, CONF_TYPE: type, CONF_SUBTYPE: "button", @@ -136,51 +120,33 @@ async def test_get_triggers_button(hass): ] triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, coap_wrapper.device_id + hass, DeviceAutomationType.TRIGGER, device.id ) - + triggers = [value for value in triggers if value["domain"] == DOMAIN] assert_lists_same(triggers, expected_triggers) -async def test_get_triggers_non_initialized_devices(hass): +async def test_get_triggers_non_initialized_devices( + hass, mock_block_device, monkeypatch +): """Test we get the empty triggers for non-initialized devices.""" - await async_setup_component(hass, "shelly", {}) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={"sleep_period": 43200, "model": "SHDW-2", "host": "1.2.3.4"}, - unique_id="12345678", - ) - config_entry.add_to_hass(hass) - - device = Mock( - blocks=None, - settings=None, - shelly=None, - update=AsyncMock(), - initialized=False, - ) - - hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} - hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} - coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - BLOCK - ] = BlockDeviceWrapper(hass, config_entry, device) - - coap_wrapper.async_setup() + monkeypatch.setattr(mock_block_device, "initialized", False) + entry = await init_integration(hass, 1) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] expected_triggers = [] triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, coap_wrapper.device_id + hass, DeviceAutomationType.TRIGGER, device.id ) - + triggers = [value for value in triggers if value["domain"] == DOMAIN] assert_lists_same(triggers, expected_triggers) -async def test_get_triggers_for_invalid_device_id(hass, device_reg, coap_wrapper): +async def test_get_triggers_for_invalid_device_id(hass, device_reg, mock_block_device): """Test error raised for invalid shelly device_id.""" - assert coap_wrapper + await init_integration(hass, 1) config_entry = MockConfigEntry(domain=DOMAIN, data={}) config_entry.add_to_hass(hass) invalid_device = device_reg.async_get_or_create( @@ -194,9 +160,11 @@ async def test_get_triggers_for_invalid_device_id(hass, device_reg, coap_wrapper ) -async def test_if_fires_on_click_event_block_device(hass, calls, coap_wrapper): +async def test_if_fires_on_click_event_block_device(hass, calls, mock_block_device): """Test for click_event trigger firing for block device.""" - assert coap_wrapper + entry = await init_integration(hass, 1) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] assert await async_setup_component( hass, @@ -207,7 +175,7 @@ async def test_if_fires_on_click_event_block_device(hass, calls, coap_wrapper): "trigger": { CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, - CONF_DEVICE_ID: coap_wrapper.device_id, + CONF_DEVICE_ID: device.id, CONF_TYPE: "single", CONF_SUBTYPE: "button1", }, @@ -221,7 +189,7 @@ async def test_if_fires_on_click_event_block_device(hass, calls, coap_wrapper): ) message = { - CONF_DEVICE_ID: coap_wrapper.device_id, + CONF_DEVICE_ID: device.id, ATTR_CLICK_TYPE: "single", ATTR_CHANNEL: 1, } @@ -232,9 +200,11 @@ async def test_if_fires_on_click_event_block_device(hass, calls, coap_wrapper): assert calls[0].data["some"] == "test_trigger_single_click" -async def test_if_fires_on_click_event_rpc_device(hass, calls, rpc_wrapper): +async def test_if_fires_on_click_event_rpc_device(hass, calls, mock_rpc_device): """Test for click_event trigger firing for rpc device.""" - assert rpc_wrapper + entry = await init_integration(hass, 2) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] assert await async_setup_component( hass, @@ -245,7 +215,7 @@ async def test_if_fires_on_click_event_rpc_device(hass, calls, rpc_wrapper): "trigger": { CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, - CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DEVICE_ID: device.id, CONF_TYPE: "single_push", CONF_SUBTYPE: "button1", }, @@ -259,7 +229,7 @@ async def test_if_fires_on_click_event_rpc_device(hass, calls, rpc_wrapper): ) message = { - CONF_DEVICE_ID: rpc_wrapper.device_id, + CONF_DEVICE_ID: device.id, ATTR_CLICK_TYPE: "single_push", ATTR_CHANNEL: 1, } @@ -270,9 +240,9 @@ async def test_if_fires_on_click_event_rpc_device(hass, calls, rpc_wrapper): assert calls[0].data["some"] == "test_trigger_single_push" -async def test_validate_trigger_block_device_not_ready(hass, calls, coap_wrapper): +async def test_validate_trigger_block_device_not_ready(hass, calls, mock_block_device): """Test validate trigger config when block device is not ready.""" - assert coap_wrapper + await init_integration(hass, 1) assert await async_setup_component( hass, @@ -307,10 +277,8 @@ async def test_validate_trigger_block_device_not_ready(hass, calls, coap_wrapper assert calls[0].data["some"] == "test_trigger_single_click" -async def test_validate_trigger_rpc_device_not_ready(hass, calls, rpc_wrapper): +async def test_validate_trigger_rpc_device_not_ready(hass, calls, mock_rpc_device): """Test validate trigger config when RPC device is not ready.""" - assert rpc_wrapper - assert await async_setup_component( hass, automation.DOMAIN, @@ -344,9 +312,11 @@ async def test_validate_trigger_rpc_device_not_ready(hass, calls, rpc_wrapper): assert calls[0].data["some"] == "test_trigger_single_push" -async def test_validate_trigger_invalid_triggers(hass, coap_wrapper): +async def test_validate_trigger_invalid_triggers(hass, mock_block_device): """Test for click_event with invalid triggers.""" - assert coap_wrapper + entry = await init_integration(hass, 1) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] assert await async_setup_component( hass, @@ -357,7 +327,7 @@ async def test_validate_trigger_invalid_triggers(hass, coap_wrapper): "trigger": { CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, - CONF_DEVICE_ID: coap_wrapper.device_id, + CONF_DEVICE_ID: device.id, CONF_TYPE: "single", CONF_SUBTYPE: "button3", }, diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index 137149f160899aa557e647511cf518a6c64c5a4f..a99b28d48e0153f131dc6e55033c3695ff548fc2 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -1,4 +1,4 @@ -"""The scene tests for the myq platform.""" +"""Tests for Shelly diagnostics platform.""" from aiohttp import ClientSession from homeassistant.components.diagnostics import REDACTED @@ -6,6 +6,7 @@ from homeassistant.components.shelly.const import DOMAIN from homeassistant.components.shelly.diagnostics import TO_REDACT from homeassistant.core import HomeAssistant +from . import init_integration from .conftest import MOCK_STATUS_COAP from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -14,10 +15,10 @@ RELAY_BLOCK_ID = 0 async def test_block_config_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSession, coap_wrapper + hass: HomeAssistant, hass_client: ClientSession, mock_block_device ): """Test config entry diagnostics for block device.""" - assert coap_wrapper + await init_integration(hass, 1) entry = hass.config_entries.async_entries(DOMAIN)[0] entry_dict = entry.as_dict() @@ -30,9 +31,9 @@ async def test_block_config_entry_diagnostics( assert result == { "entry": entry_dict, "device_info": { - "name": coap_wrapper.name, - "model": coap_wrapper.model, - "sw_version": coap_wrapper.sw_version, + "name": "Test name", + "model": "SHSW-25", + "sw_version": "some fw string", }, "device_settings": {"coiot": {"update_period": 15}}, "device_status": MOCK_STATUS_COAP, @@ -42,10 +43,10 @@ async def test_block_config_entry_diagnostics( async def test_rpc_config_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSession, - rpc_wrapper, + mock_rpc_device, ): """Test config entry diagnostics for rpc device.""" - assert rpc_wrapper + await init_integration(hass, 2) entry = hass.config_entries.async_entries(DOMAIN)[0] entry_dict = entry.as_dict() @@ -58,9 +59,9 @@ async def test_rpc_config_entry_diagnostics( assert result == { "entry": entry_dict, "device_info": { - "name": rpc_wrapper.name, - "model": rpc_wrapper.model, - "sw_version": rpc_wrapper.sw_version, + "name": "Test name", + "model": "SHSW-25", + "sw_version": "some fw string", }, "device_settings": {}, "device_status": { diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py new file mode 100644 index 0000000000000000000000000000000000000000..f795b79132f78a66b1b86acec729a29d9157c02f --- /dev/null +++ b/tests/components/shelly/test_init.py @@ -0,0 +1,184 @@ +"""Test cases for the Shelly component.""" + +from unittest.mock import AsyncMock + +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +import pytest + +from homeassistant.components.shelly.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.helpers import device_registry +from homeassistant.setup import async_setup_component + +from . import MOCK_MAC, init_integration + +from tests.common import MockConfigEntry + + +async def test_custom_coap_port(hass, mock_block_device, caplog): + """Test custom coap port.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"coap_port": 7632}}, + ) + await hass.async_block_till_done() + + await init_integration(hass, 1) + assert "Starting CoAP context with UDP port 7632" in caplog.text + + +@pytest.mark.parametrize("gen", [1, 2]) +async def test_shared_device_mac( + hass, gen, mock_block_device, mock_rpc_device, device_reg, caplog +): + """Test first time shared device with another domain.""" + config_entry = MockConfigEntry(domain="test", data={}, unique_id="some_id") + config_entry.add_to_hass(hass) + device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={ + ( + device_registry.CONNECTION_NETWORK_MAC, + device_registry.format_mac(MOCK_MAC), + ) + }, + ) + await init_integration(hass, gen, sleep_period=1000) + assert "will resume when device is online" in caplog.text + + +async def test_setup_entry_not_shelly(hass, caplog): + """Test not Shelly entry.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) is False + await hass.async_block_till_done() + + assert "probably comes from a custom integration" in caplog.text + + +@pytest.mark.parametrize("gen", [1, 2]) +async def test_device_connection_error( + hass, gen, mock_block_device, mock_rpc_device, monkeypatch +): + """Test device connection error.""" + monkeypatch.setattr( + mock_block_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + ) + monkeypatch.setattr( + mock_rpc_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + ) + + entry = await init_integration(hass, gen) + assert entry.state == ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("gen", [1, 2]) +async def test_device_auth_error( + hass, gen, mock_block_device, mock_rpc_device, monkeypatch +): + """Test device authentication error.""" + monkeypatch.setattr( + mock_block_device, "initialize", AsyncMock(side_effect=InvalidAuthError) + ) + monkeypatch.setattr( + mock_rpc_device, "initialize", AsyncMock(side_effect=InvalidAuthError) + ) + + entry = await init_integration(hass, gen) + assert entry.state == ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id + + +@pytest.mark.parametrize("entry_sleep, device_sleep", [(None, 0), (1000, 1000)]) +async def test_sleeping_block_device_online( + hass, entry_sleep, device_sleep, mock_block_device, device_reg, caplog +): + """Test sleeping block device online.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id="shelly") + config_entry.add_to_hass(hass) + device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={ + ( + device_registry.CONNECTION_NETWORK_MAC, + device_registry.format_mac(MOCK_MAC), + ) + }, + ) + + entry = await init_integration(hass, 1, sleep_period=entry_sleep) + assert "will resume when device is online" in caplog.text + + mock_block_device.mock_update() + assert "online, resuming setup" in caplog.text + assert entry.data["sleep_period"] == device_sleep + + +@pytest.mark.parametrize("entry_sleep, device_sleep", [(None, 0), (1000, 1000)]) +async def test_sleeping_rpc_device_online( + hass, entry_sleep, device_sleep, mock_rpc_device, caplog +): + """Test sleeping RPC device online.""" + entry = await init_integration(hass, 2, sleep_period=entry_sleep) + assert "will resume when device is online" in caplog.text + + mock_rpc_device.mock_update() + assert "online, resuming setup" in caplog.text + assert entry.data["sleep_period"] == device_sleep + + +@pytest.mark.parametrize( + "gen, entity_id", + [ + (1, "switch.test_name_channel_1"), + (2, "switch.test_switch_0"), + ], +) +async def test_entry_unload(hass, gen, entity_id, mock_block_device, mock_rpc_device): + """Test entry unload.""" + entry = await init_integration(hass, gen) + + assert entry.state is ConfigEntryState.LOADED + assert hass.states.get(entity_id).state is STATE_ON + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert hass.states.get(entity_id).state is STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "gen, entity_id", + [ + (1, "switch.test_name_channel_1"), + (2, "switch.test_switch_0"), + ], +) +async def test_entry_unload_device_not_ready( + hass, gen, entity_id, mock_block_device, mock_rpc_device +): + """Test entry unload when device is not ready.""" + entry = await init_integration(hass, gen, sleep_period=1000) + + assert entry.state is ConfigEntryState.LOADED + assert hass.states.get(entity_id) is None + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py new file mode 100644 index 0000000000000000000000000000000000000000..b0162f43e1352ca480872fc18447a2809ae46a83 --- /dev/null +++ b/tests/components/shelly/test_light.py @@ -0,0 +1,385 @@ +"""Tests for Shelly light platform.""" + +import pytest + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ATTR_EFFECT_LIST, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + ATTR_TRANSITION, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + ColorMode, + LightEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_OFF, + STATE_ON, +) + +from . import init_integration + +RELAY_BLOCK_ID = 0 +LIGHT_BLOCK_ID = 2 + + +async def test_block_device_rgbw_bulb(hass, mock_block_device): + """Test block device RGBW bulb.""" + await init_integration(hass, 1, model="SHBLB-1") + + # Test initial + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_RGBW_COLOR] == (45, 55, 65, 70) + assert attributes[ATTR_BRIGHTNESS] == 48 + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.RGBW, + ] + assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT + assert len(attributes[ATTR_EFFECT_LIST]) == 7 + assert attributes[ATTR_EFFECT] == "Off" + + # Turn off + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_name_channel_1"}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="off" + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_OFF + + # Turn on, RGBW = [70, 80, 90, 20], brightness = 33, effect = Flash + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.test_name_channel_1", + ATTR_RGBW_COLOR: [70, 80, 90, 30], + ATTR_BRIGHTNESS: 33, + ATTR_EFFECT: "Flash", + }, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", gain=13, brightness=13, red=70, green=80, blue=90, white=30, effect=3 + ) + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_COLOR_MODE] == ColorMode.RGBW + assert attributes[ATTR_RGBW_COLOR] == (70, 80, 90, 30) + assert attributes[ATTR_BRIGHTNESS] == 33 + assert attributes[ATTR_EFFECT] == "Flash" + + # Turn on, COLOR_TEMP_KELVIN = 3500 + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_name_channel_1", ATTR_COLOR_TEMP_KELVIN: 3500}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", temp=3500, mode="white" + ) + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 + + +async def test_block_device_rgb_bulb(hass, mock_block_device, monkeypatch, caplog): + """Test block device RGB bulb.""" + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "mode") + await init_integration(hass, 1, model="SHCB-1") + + # Test initial + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_RGB_COLOR] == (45, 55, 65) + assert attributes[ATTR_BRIGHTNESS] == 48 + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.RGB, + ] + assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT + assert len(attributes[ATTR_EFFECT_LIST]) == 4 + assert attributes[ATTR_EFFECT] == "Off" + + # Turn off + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_name_channel_1"}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="off" + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_OFF + + # Turn on, RGB = [70, 80, 90], brightness = 33, effect = Flash + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.test_name_channel_1", + ATTR_RGB_COLOR: [70, 80, 90], + ATTR_BRIGHTNESS: 33, + ATTR_EFFECT: "Flash", + }, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", gain=13, brightness=13, red=70, green=80, blue=90, effect=3 + ) + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_COLOR_MODE] == ColorMode.RGB + assert attributes[ATTR_RGB_COLOR] == (70, 80, 90) + assert attributes[ATTR_BRIGHTNESS] == 33 + assert attributes[ATTR_EFFECT] == "Flash" + + # Turn on, COLOR_TEMP_KELVIN = 3500 + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_name_channel_1", ATTR_COLOR_TEMP_KELVIN: 3500}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", temp=3500, mode="white" + ) + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 + + # Turn on with unsupported effect + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_name_channel_1", ATTR_EFFECT: "Breath"}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", mode="color" + ) + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_EFFECT] == "Off" + assert "Effect 'Breath' not supported" in caplog.text + + +async def test_block_device_white_bulb(hass, mock_block_device, monkeypatch, caplog): + """Test block device white bulb.""" + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "red") + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "green") + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "blue") + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "mode") + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "colorTemp") + monkeypatch.delattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "effect") + await init_integration(hass, 1, model="SHVIN-1") + + # Test initial + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] + assert attributes[ATTR_SUPPORTED_FEATURES] == 0 + + # Turn off + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_name_channel_1"}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="off" + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_OFF + + # Turn on, brightness = 33 + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_name_channel_1", ATTR_BRIGHTNESS: 33}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", gain=13, brightness=13 + ) + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_BRIGHTNESS] == 33 + + +@pytest.mark.parametrize( + "model", + [ + "SHBDUO-1", + "SHCB-1", + "SHDM-1", + "SHDM-2", + "SHRGBW2", + "SHVIN-1", + ], +) +async def test_block_device_support_transition( + hass, mock_block_device, model, monkeypatch +): + """Test block device supports transition.""" + monkeypatch.setitem( + mock_block_device.settings, "fw", "20220809-122808/v1.12-g99f7e0b" + ) + await init_integration(hass, 1, model=model) + + # Test initial + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert attributes[ATTR_SUPPORTED_FEATURES] & LightEntityFeature.TRANSITION + + # Turn on, TRANSITION = 4 + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_name_channel_1", ATTR_TRANSITION: 4}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="on", transition=4000 + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_ON + + # Turn off, TRANSITION = 6, limit to 5000ms + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_name_channel_1", ATTR_TRANSITION: 6}, + blocking=True, + ) + mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( + turn="off", transition=5000 + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_OFF + + +async def test_block_device_relay_app_type_light(hass, mock_block_device, monkeypatch): + """Test block device relay in app type set to light mode.""" + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "red") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "green") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "blue") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "mode") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "gain") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "brightness") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "effect") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "colorTemp") + monkeypatch.setitem( + mock_block_device.settings["relays"][RELAY_BLOCK_ID], "appliance_type", "light" + ) + await init_integration(hass, 1) + assert hass.states.get("switch.test_name_channel_1") is None + + # Test initial + state = hass.states.get("light.test_name_channel_1") + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] + assert attributes[ATTR_SUPPORTED_FEATURES] == 0 + + # Turn off + mock_block_device.blocks[RELAY_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_name_channel_1"}, + blocking=True, + ) + mock_block_device.blocks[RELAY_BLOCK_ID].set_state.assert_called_once_with( + turn="off" + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_OFF + + # Turn on + mock_block_device.blocks[RELAY_BLOCK_ID].set_state.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_name_channel_1"}, + blocking=True, + ) + mock_block_device.blocks[RELAY_BLOCK_ID].set_state.assert_called_once_with( + turn="on" + ) + state = hass.states.get("light.test_name_channel_1") + assert state.state == STATE_ON + + +async def test_block_device_no_light_blocks(hass, mock_block_device, monkeypatch): + """Test block device without light blocks.""" + monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "type", "roller") + await init_integration(hass, 1) + assert hass.states.get("light.test_name_channel_1") is None + + +async def test_rpc_device_switch_type_lights_mode(hass, mock_rpc_device, monkeypatch): + """Test RPC device with switch in consumption type lights mode.""" + monkeypatch.setitem( + mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] + ) + await init_integration(hass, 2) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_switch_0"}, + blocking=True, + ) + assert hass.states.get("light.test_switch_0").state == STATE_ON + + monkeypatch.setitem(mock_rpc_device.status["switch:0"], "output", False) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_switch_0"}, + blocking=True, + ) + mock_rpc_device.mock_update() + assert hass.states.get("light.test_switch_0").state == STATE_OFF diff --git a/tests/components/shelly/test_logbook.py b/tests/components/shelly/test_logbook.py index 5e267dcfd8f4b58893c6b8d3469def2cdf8269b7..b176b37c7e914841003c6b44da564a22f5099da7 100644 --- a/tests/components/shelly/test_logbook.py +++ b/tests/components/shelly/test_logbook.py @@ -7,14 +7,23 @@ from homeassistant.components.shelly.const import ( EVENT_SHELLY_CLICK, ) from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.helpers.device_registry import ( + async_entries_for_config_entry, + async_get as async_get_dev_reg, +) from homeassistant.setup import async_setup_component +from . import init_integration + from tests.components.logbook.common import MockRow, mock_humanify -async def test_humanify_shelly_click_event_block_device(hass, coap_wrapper): +async def test_humanify_shelly_click_event_block_device(hass, mock_block_device): """Test humanifying Shelly click event for block device.""" - assert coap_wrapper + entry = await init_integration(hass, 1) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) @@ -24,7 +33,7 @@ async def test_humanify_shelly_click_event_block_device(hass, coap_wrapper): MockRow( EVENT_SHELLY_CLICK, { - ATTR_DEVICE_ID: coap_wrapper.device_id, + ATTR_DEVICE_ID: device.id, ATTR_DEVICE: "shellyix3-12345678", ATTR_CLICK_TYPE: "single", ATTR_CHANNEL: 1, @@ -57,9 +66,12 @@ async def test_humanify_shelly_click_event_block_device(hass, coap_wrapper): ) -async def test_humanify_shelly_click_event_rpc_device(hass, rpc_wrapper): +async def test_humanify_shelly_click_event_rpc_device(hass, mock_rpc_device): """Test humanifying Shelly click event for rpc device.""" - assert rpc_wrapper + entry = await init_integration(hass, 2) + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) @@ -69,7 +81,7 @@ async def test_humanify_shelly_click_event_rpc_device(hass, rpc_wrapper): MockRow( EVENT_SHELLY_CLICK, { - ATTR_DEVICE_ID: rpc_wrapper.device_id, + ATTR_DEVICE_ID: device.id, ATTR_DEVICE: "shellyplus1pm-12345678", ATTR_CLICK_TYPE: "single_push", ATTR_CHANNEL: 1, diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index cb93d9dace52608674af317c2ca2eec8b40131e7..458de9c655b3c7fb2642cba756daefb13b931104 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -1,4 +1,4 @@ -"""The scene tests for the myq platform.""" +"""Tests for Shelly switch platform.""" from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -7,19 +7,15 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.helpers.entity_component import async_update_entity + +from . import init_integration RELAY_BLOCK_ID = 0 -async def test_block_device_services(hass, coap_wrapper): +async def test_block_device_services(hass, mock_block_device): """Test block device turn on/off services.""" - assert coap_wrapper - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) - ) - await hass.async_block_till_done() + await init_integration(hass, 1) await hass.services.async_call( SWITCH_DOMAIN, @@ -38,72 +34,43 @@ async def test_block_device_services(hass, coap_wrapper): assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF -async def test_block_device_update(hass, coap_wrapper, monkeypatch): +async def test_block_device_update(hass, mock_block_device, monkeypatch): """Test block device update.""" - assert coap_wrapper - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) - ) - await hass.async_block_till_done() - - monkeypatch.setattr(coap_wrapper.device.blocks[RELAY_BLOCK_ID], "output", False) - await async_update_entity(hass, "switch.test_name_channel_1") - await hass.async_block_till_done() + monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "output", False) + await init_integration(hass, 1) assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF - monkeypatch.setattr(coap_wrapper.device.blocks[RELAY_BLOCK_ID], "output", True) - await async_update_entity(hass, "switch.test_name_channel_1") - await hass.async_block_till_done() + monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "output", True) + mock_block_device.mock_update() assert hass.states.get("switch.test_name_channel_1").state == STATE_ON -async def test_block_device_no_relay_blocks(hass, coap_wrapper, monkeypatch): +async def test_block_device_no_relay_blocks(hass, mock_block_device, monkeypatch): """Test block device without relay blocks.""" - assert coap_wrapper - - monkeypatch.setattr(coap_wrapper.device.blocks[RELAY_BLOCK_ID], "type", "roller") - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) - ) - await hass.async_block_till_done() + monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "type", "roller") + await init_integration(hass, 1) assert hass.states.get("switch.test_name_channel_1") is None -async def test_block_device_mode_roller(hass, coap_wrapper, monkeypatch): +async def test_block_device_mode_roller(hass, mock_block_device, monkeypatch): """Test block device in roller mode.""" - assert coap_wrapper - - monkeypatch.setitem(coap_wrapper.device.settings, "mode", "roller") - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) - ) - await hass.async_block_till_done() + monkeypatch.setitem(mock_block_device.settings, "mode", "roller") + await init_integration(hass, 1) assert hass.states.get("switch.test_name_channel_1") is None -async def test_block_device_app_type_light(hass, coap_wrapper, monkeypatch): +async def test_block_device_app_type_light(hass, mock_block_device, monkeypatch): """Test block device in app type set to light mode.""" - assert coap_wrapper - monkeypatch.setitem( - coap_wrapper.device.settings["relays"][0], "appliance_type", "light" - ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) + mock_block_device.settings["relays"][RELAY_BLOCK_ID], "appliance_type", "light" ) - await hass.async_block_till_done() + await init_integration(hass, 1) assert hass.states.get("switch.test_name_channel_1") is None -async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch): +async def test_rpc_device_services(hass, mock_rpc_device, monkeypatch): """Test RPC device turn on/off services.""" - assert rpc_wrapper - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, SWITCH_DOMAIN) - ) - await hass.async_block_till_done() + await init_integration(hass, 2) await hass.services.async_call( SWITCH_DOMAIN, @@ -113,28 +80,21 @@ async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch): ) assert hass.states.get("switch.test_switch_0").state == STATE_ON - monkeypatch.setitem(rpc_wrapper.device.status["switch:0"], "output", False) + monkeypatch.setitem(mock_rpc_device.status["switch:0"], "output", False) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test_switch_0"}, blocking=True, ) - rpc_wrapper.async_set_updated_data("") + mock_rpc_device.mock_update() assert hass.states.get("switch.test_switch_0").state == STATE_OFF -async def test_rpc_device_switch_type_lights_mode(hass, rpc_wrapper, monkeypatch): +async def test_rpc_device_switch_type_lights_mode(hass, mock_rpc_device, monkeypatch): """Test RPC device with switch in consumption type lights mode.""" - assert rpc_wrapper - monkeypatch.setitem( - rpc_wrapper.device.config["sys"]["ui_data"], - "consumption_types", - ["lights"], - ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, SWITCH_DOMAIN) + mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] ) - await hass.async_block_till_done() + await init_integration(hass, 2) assert hass.states.get("switch.test_switch_0") is None diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 4d863c59390cc1e2f619b63213734df3e9515e31..4da81e076aef3ab9e17eb374a98311f66d8bdd76 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -1,83 +1,277 @@ """Tests for Shelly update platform.""" -from homeassistant.components.shelly.const import DOMAIN -from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL -from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_UNKNOWN +from datetime import timedelta +from unittest.mock import AsyncMock + +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +import pytest + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.shelly.const import DOMAIN, REST_SENSORS_UPDATE_INTERVAL +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_registry import async_get +from homeassistant.util import dt +from . import MOCK_MAC, init_integration -async def test_block_update(hass: HomeAssistant, coap_wrapper, monkeypatch): - """Test block device update entity.""" - assert coap_wrapper +from tests.common import async_fire_time_changed + +@pytest.mark.parametrize( + "gen, domain, unique_id, object_id", + [ + (1, BINARY_SENSOR_DOMAIN, f"{MOCK_MAC}-fwupdate", "firmware_update"), + (1, BUTTON_DOMAIN, "test_name_ota_update", "ota_update"), + (1, BUTTON_DOMAIN, "test_name_ota_update_beta", "ota_update_beta"), + (2, BINARY_SENSOR_DOMAIN, f"{MOCK_MAC}-sys-fwupdate", "firmware_update"), + (2, BUTTON_DOMAIN, "test_name_ota_update", "ota_update"), + (2, BUTTON_DOMAIN, "test_name_ota_update_beta", "ota_update_beta"), + ], +) +async def test_remove_legacy_entities( + hass: HomeAssistant, + gen, + domain, + unique_id, + object_id, + mock_block_device, + mock_rpc_device, +): + """Test removes legacy update entities.""" + entity_id = f"{domain}.test_name_{object_id}" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + domain, + DOMAIN, + unique_id, + suggested_object_id=f"test_name_{object_id}", + disabled_by=None, + ) + + assert entity_registry.async_get(entity_id) is not None + + await init_integration(hass, gen) + + assert entity_registry.async_get(entity_id) is None + + +async def test_block_update(hass: HomeAssistant, mock_block_device, monkeypatch): + """Test block device update entity.""" entity_registry = async_get(hass) entity_registry.async_get_or_create( UPDATE_DOMAIN, DOMAIN, - "test-mac-fwupdate", + f"{MOCK_MAC}-fwupdate", suggested_object_id="test_name_firmware_update", disabled_by=None, ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, UPDATE_DOMAIN) + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2") + await init_integration(hass, 1) + + state = hass.states.get("update.test_name_firmware_update") + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_IN_PROGRESS] is False + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + blocking=True, ) - await hass.async_block_till_done() + assert mock_block_device.trigger_ota_update.call_count == 1 + + state = hass.states.get("update.test_name_firmware_update") + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_IN_PROGRESS] is True - # update entity - await async_update_entity(hass, "update.test_name_firmware_update") + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2") + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL) + ) await hass.async_block_till_done() + state = hass.states.get("update.test_name_firmware_update") + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "2" + assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_IN_PROGRESS] is False + + +async def test_block_beta_update(hass: HomeAssistant, mock_block_device, monkeypatch): + """Test block device beta update entity.""" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + UPDATE_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-fwupdate_beta", + suggested_object_id="test_name_beta_firmware_update", + disabled_by=None, + ) + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2") + monkeypatch.setitem(mock_block_device.status["update"], "beta_version", "") + await init_integration(hass, 1) + + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "1" + assert state.attributes[ATTR_IN_PROGRESS] is False + + monkeypatch.setitem(mock_block_device.status["update"], "beta_version", "2b") + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() - assert state + state = hass.states.get("update.test_name_beta_firmware_update") assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_IN_PROGRESS] is False await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + {ATTR_ENTITY_ID: "update.test_name_beta_firmware_update"}, blocking=True, ) - await hass.async_block_till_done() - assert coap_wrapper.device.trigger_ota_update.call_count == 1 + assert mock_block_device.trigger_ota_update.call_count == 1 - monkeypatch.setitem(coap_wrapper.device.status["update"], "old_version", None) - monkeypatch.setitem(coap_wrapper.device.status["update"], "new_version", None) + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_IN_PROGRESS] is True - # update entity - await async_update_entity(hass, "update.test_name_firmware_update") + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2b") + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL) + ) await hass.async_block_till_done() - state = hass.states.get("update.test_name_firmware_update") - assert state - assert state.state == STATE_UNKNOWN + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "2b" + assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_IN_PROGRESS] is False -async def test_rpc_update(hass: HomeAssistant, rpc_wrapper, monkeypatch): - """Test rpc device update entity.""" - assert rpc_wrapper +async def test_block_update_connection_error( + hass: HomeAssistant, mock_block_device, monkeypatch, caplog +): + """Test block device update connection error.""" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + UPDATE_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-fwupdate", + suggested_object_id="test_name_firmware_update", + disabled_by=None, + ) + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2") + monkeypatch.setattr( + mock_block_device, + "trigger_ota_update", + AsyncMock(side_effect=DeviceConnectionError), + ) + await init_integration(hass, 1) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + blocking=True, + ) + assert "Error starting OTA update" in caplog.text + +async def test_block_update_auth_error( + hass: HomeAssistant, mock_block_device, monkeypatch +): + """Test block device update authentication error.""" entity_registry = async_get(hass) entity_registry.async_get_or_create( UPDATE_DOMAIN, DOMAIN, - "12345678-sys-fwupdate", + f"{MOCK_MAC}-fwupdate", suggested_object_id="test_name_firmware_update", disabled_by=None, ) + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2") + monkeypatch.setattr( + mock_block_device, + "trigger_ota_update", + AsyncMock(side_effect=InvalidAuthError), + ) + entry = await init_integration(hass, 1) + + assert entry.state == ConfigEntryState.LOADED - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, UPDATE_DOMAIN) + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + blocking=True, ) - await hass.async_block_till_done() - # update entity - await async_update_entity(hass, "update.test_name_firmware_update") - await hass.async_block_till_done() - state = hass.states.get("update.test_name_firmware_update") + assert entry.state == ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id + + +async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch): + """Test RPC device update entity.""" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + UPDATE_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-sys-fwupdate", + suggested_object_id="test_name_firmware_update", + disabled_by=None, + ) + monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") + monkeypatch.setitem( + mock_rpc_device.status["sys"], + "available_updates", + { + "stable": {"version": "2"}, + }, + ) + await init_integration(hass, 2) - assert state + state = hass.states.get("update.test_name_firmware_update") assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_IN_PROGRESS] is False await hass.services.async_call( UPDATE_DOMAIN, @@ -85,16 +279,189 @@ async def test_rpc_update(hass: HomeAssistant, rpc_wrapper, monkeypatch): {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, blocking=True, ) - await hass.async_block_till_done() - assert rpc_wrapper.device.trigger_ota_update.call_count == 1 + assert mock_rpc_device.trigger_ota_update.call_count == 1 - monkeypatch.setitem(rpc_wrapper.device.status["sys"], "available_updates", {}) - rpc_wrapper.device.shelly = None + state = hass.states.get("update.test_name_firmware_update") + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_IN_PROGRESS] is True - # update entity - await async_update_entity(hass, "update.test_name_firmware_update") + monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL) + ) await hass.async_block_till_done() + state = hass.states.get("update.test_name_firmware_update") + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "2" + assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_IN_PROGRESS] is False + + +async def test_rpc_beta_update(hass: HomeAssistant, mock_rpc_device, monkeypatch): + """Test RPC device beta update entity.""" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + UPDATE_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-sys-fwupdate_beta", + suggested_object_id="test_name_beta_firmware_update", + disabled_by=None, + ) + monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") + monkeypatch.setitem( + mock_rpc_device.status["sys"], + "available_updates", + { + "stable": {"version": "2"}, + "beta": {"version": ""}, + }, + ) + await init_integration(hass, 2) + + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "1" + assert state.attributes[ATTR_IN_PROGRESS] is False + + monkeypatch.setitem( + mock_rpc_device.status["sys"], + "available_updates", + { + "stable": {"version": "2"}, + "beta": {"version": "2b"}, + }, + ) + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_IN_PROGRESS] is False + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_name_beta_firmware_update"}, + blocking=True, + ) + assert mock_rpc_device.trigger_ota_update.call_count == 1 + + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "1" + assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_IN_PROGRESS] is True + + monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2b") + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + + state = hass.states.get("update.test_name_beta_firmware_update") + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "2b" + assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_IN_PROGRESS] is False + + +@pytest.mark.parametrize( + "exc, error", + [ + (DeviceConnectionError, "Error starting OTA update"), + (RpcCallError(-1, "error"), "OTA update request error"), + ], +) +async def test_rpc_update__errors( + hass: HomeAssistant, exc, error, mock_rpc_device, monkeypatch, caplog +): + """Test RPC device update connection/call errors.""" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + UPDATE_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-sys-fwupdate", + suggested_object_id="test_name_firmware_update", + disabled_by=None, + ) + monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") + monkeypatch.setitem( + mock_rpc_device.status["sys"], + "available_updates", + { + "stable": {"version": "2"}, + "beta": {"version": ""}, + }, + ) + monkeypatch.setattr( + mock_rpc_device, "trigger_ota_update", AsyncMock(side_effect=exc) + ) + await init_integration(hass, 2) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + blocking=True, + ) + assert error in caplog.text + + +async def test_rpc_update_auth_error( + hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog +): + """Test RPC device update authentication error.""" + entity_registry = async_get(hass) + entity_registry.async_get_or_create( + UPDATE_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-sys-fwupdate", + suggested_object_id="test_name_firmware_update", + disabled_by=None, + ) + monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") + monkeypatch.setitem( + mock_rpc_device.status["sys"], + "available_updates", + { + "stable": {"version": "2"}, + "beta": {"version": ""}, + }, + ) + monkeypatch.setattr( + mock_rpc_device, + "trigger_ota_update", + AsyncMock(side_effect=InvalidAuthError), + ) + entry = await init_integration(hass, 2) + + assert entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + blocking=True, + ) + + assert entry.state == ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN - assert state - assert state.state == STATE_UNKNOWN + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/simplepush/test_config_flow.py b/tests/components/simplepush/test_config_flow.py index 6f0d6a73aa4f56bac537d96f25f9ce6937652dd8..02db81ceaa77e3fe951d747b9f9bdef42ecf7c0c 100644 --- a/tests/components/simplepush/test_config_flow.py +++ b/tests/components/simplepush/test_config_flow.py @@ -29,9 +29,7 @@ def simplepush_setup_fixture(): @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" - ): + with patch("homeassistant.components.simplepush.config_flow.send"): yield diff --git a/tests/components/simplisafe/conftest.py b/tests/components/simplisafe/conftest.py index 54ab7fbe9d788a05fcfb4a9f32b83f2bb658e79a..165f71cde04b29bd69630fb340388741091b7804 100644 --- a/tests/components/simplisafe/conftest.py +++ b/tests/components/simplisafe/conftest.py @@ -58,25 +58,25 @@ def credentials_config_fixture(): } -@pytest.fixture(name="data_latest_event", scope="session") +@pytest.fixture(name="data_latest_event", scope="package") def data_latest_event_fixture(): """Define latest event data.""" return json.loads(load_fixture("latest_event_data.json", "simplisafe")) -@pytest.fixture(name="data_sensor", scope="session") +@pytest.fixture(name="data_sensor", scope="package") def data_sensor_fixture(): """Define sensor data.""" return json.loads(load_fixture("sensor_data.json", "simplisafe")) -@pytest.fixture(name="data_settings", scope="session") +@pytest.fixture(name="data_settings", scope="package") def data_settings_fixture(): """Define settings data.""" return json.loads(load_fixture("settings_data.json", "simplisafe")) -@pytest.fixture(name="data_subscription", scope="session") +@pytest.fixture(name="data_subscription", scope="package") def data_subscription_fixture(): """Define subscription data.""" return json.loads(load_fixture("subscription_data.json", "simplisafe")) diff --git a/tests/components/simplisafe/test_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py index 446d9d5e9e37475d12c71cc0581d920b9c9b3c5a..f7a88fe0d0617b8f527e4eafc98bfb620def856d 100644 --- a/tests/components/simplisafe/test_diagnostics.py +++ b/tests/components/simplisafe/test_diagnostics.py @@ -8,9 +8,17 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_simplisa """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { - "options": { - "code": REDACTED, - }, + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "simplisafe", + "title": REDACTED, + "data": {"token": REDACTED, "username": REDACTED}, + "options": {"code": REDACTED}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "subscription_data": { "system_123": { diff --git a/tests/components/siren/test_recorder.py b/tests/components/siren/test_recorder.py index aaf1679478a0a3c8dfa4a82cabea3af60f81d78a..c93a1a8c8e10c2e1f5d9674e5c6d22817de71563 100644 --- a/tests/components/siren/test_recorder.py +++ b/tests/components/siren/test_recorder.py @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test siren registered attributes to be excluded.""" await async_setup_component( hass, siren.DOMAIN, {siren.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/sma/conftest.py b/tests/components/sma/conftest.py index b953d8692a8e5144d23e5b015f6cbf97bbd6669f..2ce5db5e0ca7ff45209f128b2c05ae2bc300706a 100644 --- a/tests/components/sma/conftest.py +++ b/tests/components/sma/conftest.py @@ -1,7 +1,7 @@ """Fixtures for sma tests.""" from unittest.mock import patch -from pysma.const import DEVCLASS_INVERTER +from pysma.const import GENERIC_SENSORS from pysma.definitions import sensor_map from pysma.sensor import Sensors import pytest @@ -32,7 +32,7 @@ async def init_integration(hass, mock_config_entry): mock_config_entry.add_to_hass(hass) with patch("pysma.SMA.read"), patch( - "pysma.SMA.get_sensors", return_value=Sensors(sensor_map[DEVCLASS_INVERTER]) + "pysma.SMA.get_sensors", return_value=Sensors(sensor_map[GENERIC_SENSORS]) ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/snooz/__init__.py b/tests/components/snooz/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d5802642c37338e755f11a70027d8e56f999a79f --- /dev/null +++ b/tests/components/snooz/__init__.py @@ -0,0 +1,105 @@ +"""Tests for the Snooz component.""" +from __future__ import annotations + +from dataclasses import dataclass +from unittest.mock import patch + +from bleak import BLEDevice +from pysnooz.commands import SnoozCommandData +from pysnooz.testing import MockSnoozDevice + +from homeassistant.components.snooz.const import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.common import MockConfigEntry + +TEST_ADDRESS = "00:00:00:00:AB:CD" +TEST_SNOOZ_LOCAL_NAME = "Snooz-ABCD" +TEST_SNOOZ_DISPLAY_NAME = "Snooz ABCD" +TEST_PAIRING_TOKEN = "deadbeef" + +NOT_SNOOZ_SERVICE_INFO = BluetoothServiceInfo( + name="Definitely not snooz", + address=TEST_ADDRESS, + rssi=-63, + manufacturer_data={3234: b"\x00\x01"}, + service_data={}, + service_uuids=[], + source="local", +) + +SNOOZ_SERVICE_INFO_PAIRING = BluetoothServiceInfo( + name=TEST_SNOOZ_LOCAL_NAME, + address=TEST_ADDRESS, + rssi=-63, + manufacturer_data={65552: bytes([4]) + bytes.fromhex(TEST_PAIRING_TOKEN)}, + service_uuids=[ + "80c37f00-cc16-11e4-8830-0800200c9a66", + "90759319-1668-44da-9ef3-492d593bd1e5", + ], + service_data={}, + source="local", +) + +SNOOZ_SERVICE_INFO_NOT_PAIRING = BluetoothServiceInfo( + name=TEST_SNOOZ_LOCAL_NAME, + address=TEST_ADDRESS, + rssi=-63, + manufacturer_data={65552: bytes([4]) + bytes([0] * 8)}, + service_uuids=[ + "80c37f00-cc16-11e4-8830-0800200c9a66", + "90759319-1668-44da-9ef3-492d593bd1e5", + ], + service_data={}, + source="local", +) + + +@dataclass +class SnoozFixture: + """Snooz test fixture.""" + + entry: MockConfigEntry + device: MockSnoozDevice + + +async def create_mock_snooz( + connected: bool = True, + initial_state: SnoozCommandData = SnoozCommandData(on=False, volume=0), +) -> MockSnoozDevice: + """Create a mock device.""" + + ble_device = SNOOZ_SERVICE_INFO_NOT_PAIRING + device = MockSnoozDevice(ble_device, initial_state=initial_state) + + # execute a command to initiate the connection + if connected is True: + await device.async_execute_command(initial_state) + + return device + + +async def create_mock_snooz_config_entry( + hass: HomeAssistant, device: MockSnoozDevice +) -> MockConfigEntry: + """Create a mock config entry.""" + + with patch( + "homeassistant.components.snooz.SnoozDevice", return_value=device + ), patch( + "homeassistant.components.snooz.async_ble_device_from_address", + return_value=BLEDevice(device.address, device.name), + ): + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + data={CONF_ADDRESS: TEST_ADDRESS, CONF_TOKEN: TEST_PAIRING_TOKEN}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/snooz/conftest.py b/tests/components/snooz/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..f99dcfeba727b7f140c5333cfb4013a3896daa58 --- /dev/null +++ b/tests/components/snooz/conftest.py @@ -0,0 +1,23 @@ +"""Snooz test fixtures and configuration.""" +from __future__ import annotations + +import pytest + +from homeassistant.core import HomeAssistant + +from . import SnoozFixture, create_mock_snooz, create_mock_snooz_config_entry + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" + + +@pytest.fixture() +async def mock_connected_snooz(hass: HomeAssistant): + """Mock a Snooz configuration entry and device.""" + + device = await create_mock_snooz() + entry = await create_mock_snooz_config_entry(hass, device) + + yield SnoozFixture(entry, device) diff --git a/tests/components/snooz/test_config.py b/tests/components/snooz/test_config.py new file mode 100644 index 0000000000000000000000000000000000000000..e8848aa48e0b107cba3236531c8d5374fdd7f5c7 --- /dev/null +++ b/tests/components/snooz/test_config.py @@ -0,0 +1,26 @@ +"""Test Snooz configuration.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from . import SnoozFixture + + +async def test_removing_entry_cleans_up_connections( + hass: HomeAssistant, mock_connected_snooz: SnoozFixture +): + """Tests setup and removal of a config entry, ensuring connections are cleaned up.""" + await hass.config_entries.async_remove(mock_connected_snooz.entry.entry_id) + await hass.async_block_till_done() + + assert not mock_connected_snooz.device.is_connected + + +async def test_reloading_entry_cleans_up_connections( + hass: HomeAssistant, mock_connected_snooz: SnoozFixture +): + """Test reloading an entry disconnects any existing connections.""" + await hass.config_entries.async_reload(mock_connected_snooz.entry.entry_id) + await hass.async_block_till_done() + + assert not mock_connected_snooz.device.is_connected diff --git a/tests/components/snooz/test_config_flow.py b/tests/components/snooz/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..65076bf2e038a78e84298bc9d049fd3f25c68b8f --- /dev/null +++ b/tests/components/snooz/test_config_flow.py @@ -0,0 +1,325 @@ +"""Test the Snooz config flow.""" +from __future__ import annotations + +import asyncio +from asyncio import Event +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.snooz import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + NOT_SNOOZ_SERVICE_INFO, + SNOOZ_SERVICE_INFO_NOT_PAIRING, + SNOOZ_SERVICE_INFO_PAIRING, + TEST_ADDRESS, + TEST_PAIRING_TOKEN, + TEST_SNOOZ_DISPLAY_NAME, +) + +from tests.common import MockConfigEntry + + +async def test_async_step_bluetooth_valid_device(hass: HomeAssistant): + """Test discovery via bluetooth with a valid device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_PAIRING, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + await _test_setup_entry(hass, result["flow_id"]) + + +async def test_async_step_bluetooth_waits_to_pair(hass: HomeAssistant): + """Test discovery via bluetooth with a device that's not in pairing mode, but enters pairing mode to complete setup.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_NOT_PAIRING, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + await _test_pairs(hass, result["flow_id"]) + + +async def test_async_step_bluetooth_retries_pairing(hass: HomeAssistant): + """Test discovery via bluetooth with a device that's not in pairing mode, times out waiting, but eventually complete setup.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_NOT_PAIRING, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + retry_id = await _test_pairs_timeout(hass, result["flow_id"]) + await _test_pairs(hass, retry_id) + + +async def test_async_step_bluetooth_not_snooz(hass: HomeAssistant): + """Test discovery via bluetooth not Snooz.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=NOT_SNOOZ_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_supported" + + +async def test_async_step_user_no_devices_found(hass: HomeAssistant): + """Test setup from service info cache with no devices found.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_user_with_found_devices(hass: HomeAssistant): + """Test setup from service info cache with devices found.""" + with patch( + "homeassistant.components.snooz.config_flow.async_discovered_service_info", + return_value=[SNOOZ_SERVICE_INFO_PAIRING], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["data_schema"] + # ensure discovered devices are listed as options + assert result["data_schema"].schema["name"].container == [TEST_SNOOZ_DISPLAY_NAME] + await _test_setup_entry( + hass, result["flow_id"], {CONF_NAME: TEST_SNOOZ_DISPLAY_NAME} + ) + + +async def test_async_step_user_with_found_devices_waits_to_pair(hass: HomeAssistant): + """Test setup from service info cache with devices found that require pairing mode.""" + with patch( + "homeassistant.components.snooz.config_flow.async_discovered_service_info", + return_value=[SNOOZ_SERVICE_INFO_NOT_PAIRING], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + await _test_pairs(hass, result["flow_id"], {CONF_NAME: TEST_SNOOZ_DISPLAY_NAME}) + + +async def test_async_step_user_with_found_devices_retries_pairing(hass: HomeAssistant): + """Test setup from service info cache with devices found that require pairing mode, times out, then completes.""" + with patch( + "homeassistant.components.snooz.config_flow.async_discovered_service_info", + return_value=[SNOOZ_SERVICE_INFO_NOT_PAIRING], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + user_input = {CONF_NAME: TEST_SNOOZ_DISPLAY_NAME} + + retry_id = await _test_pairs_timeout(hass, result["flow_id"], user_input) + await _test_pairs(hass, retry_id, user_input) + + +async def test_async_step_user_device_added_between_steps(hass: HomeAssistant): + """Test the device gets added via another flow between steps.""" + with patch( + "homeassistant.components.snooz.config_flow.async_discovered_service_info", + return_value=[SNOOZ_SERVICE_INFO_PAIRING], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + data={CONF_NAME: TEST_SNOOZ_DISPLAY_NAME, CONF_TOKEN: TEST_PAIRING_TOKEN}, + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.snooz.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_NAME: TEST_SNOOZ_DISPLAY_NAME}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_async_step_user_with_found_devices_already_setup(hass: HomeAssistant): + """Test setup from service info cache with devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + data={CONF_NAME: TEST_SNOOZ_DISPLAY_NAME, CONF_TOKEN: TEST_PAIRING_TOKEN}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.snooz.config_flow.async_discovered_service_info", + return_value=[SNOOZ_SERVICE_INFO_PAIRING], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_bluetooth_devices_already_setup(hass: HomeAssistant): + """Test we can't start a flow if there is already a config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + data={CONF_NAME: TEST_SNOOZ_DISPLAY_NAME, CONF_TOKEN: TEST_PAIRING_TOKEN}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_PAIRING, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_async_step_bluetooth_already_in_progress(hass: HomeAssistant): + """Test we can't start a flow for the same device twice.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_PAIRING, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_PAIRING, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_async_step_user_takes_precedence_over_discovery(hass: HomeAssistant): + """Test manual setup takes precedence over discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=SNOOZ_SERVICE_INFO_PAIRING, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + with patch( + "homeassistant.components.snooz.config_flow.async_discovered_service_info", + return_value=[SNOOZ_SERVICE_INFO_PAIRING], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + + await _test_setup_entry( + hass, result["flow_id"], {CONF_NAME: TEST_SNOOZ_DISPLAY_NAME} + ) + + # Verify the original one was aborted + assert not hass.config_entries.flow.async_progress() + + +async def _test_pairs( + hass: HomeAssistant, flow_id: str, user_input: dict | None = None +) -> None: + pairing_mode_entered = Event() + + async def _async_process_advertisements( + _hass, _callback, _matcher, _mode, _timeout + ): + await pairing_mode_entered.wait() + service_info = SNOOZ_SERVICE_INFO_PAIRING + assert _callback(service_info) + return service_info + + with patch( + "homeassistant.components.snooz.config_flow.async_process_advertisements", + _async_process_advertisements, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input=user_input or {}, + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "wait_for_pairing_mode" + + pairing_mode_entered.set() + await hass.async_block_till_done() + + await _test_setup_entry(hass, result["flow_id"], user_input) + + +async def _test_pairs_timeout( + hass: HomeAssistant, flow_id: str, user_input: dict | None = None +) -> str: + with patch( + "homeassistant.components.snooz.config_flow.async_process_advertisements", + side_effect=asyncio.TimeoutError(), + ): + result = await hass.config_entries.flow.async_configure( + flow_id, user_input=user_input or {} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "wait_for_pairing_mode" + await hass.async_block_till_done() + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "pairing_timeout" + + return result2["flow_id"] + + +async def _test_setup_entry( + hass: HomeAssistant, flow_id: str, user_input: dict | None = None +) -> None: + with patch("homeassistant.components.snooz.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input=user_input or {}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_ADDRESS: TEST_ADDRESS, + CONF_TOKEN: TEST_PAIRING_TOKEN, + } + assert result["result"].unique_id == TEST_ADDRESS diff --git a/tests/components/snooz/test_fan.py b/tests/components/snooz/test_fan.py new file mode 100644 index 0000000000000000000000000000000000000000..30528336e2d2ae1c2c5b7ee24b918574b3f09197 --- /dev/null +++ b/tests/components/snooz/test_fan.py @@ -0,0 +1,264 @@ +"""Test Snooz fan entity.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import Mock + +from pysnooz.api import SnoozDeviceState, UnknownSnoozState +from pysnooz.commands import SnoozCommandResult, SnoozCommandResultStatus +from pysnooz.testing import MockSnoozDevice +import pytest + +from homeassistant.components import fan +from homeassistant.components.snooz.const import DOMAIN +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry + +from . import SnoozFixture, create_mock_snooz, create_mock_snooz_config_entry + + +async def test_turn_on(hass: HomeAssistant, snooz_fan_entity_id: str): + """Test turning on the device.""" + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) + + state = hass.states.get(snooz_fan_entity_id) + assert state.state == STATE_ON + assert ATTR_ASSUMED_STATE not in state.attributes + + +@pytest.mark.parametrize("percentage", [1, 22, 50, 99, 100]) +async def test_turn_on_with_percentage( + hass: HomeAssistant, snooz_fan_entity_id: str, percentage: int +): + """Test turning on the device with a percentage.""" + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id], fan.ATTR_PERCENTAGE: percentage}, + blocking=True, + ) + + state = hass.states.get(snooz_fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == percentage + assert ATTR_ASSUMED_STATE not in state.attributes + + +@pytest.mark.parametrize("percentage", [1, 22, 50, 99, 100]) +async def test_set_percentage( + hass: HomeAssistant, snooz_fan_entity_id: str, percentage: int +): + """Test setting the fan percentage.""" + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: [snooz_fan_entity_id], fan.ATTR_PERCENTAGE: percentage}, + blocking=True, + ) + + state = hass.states.get(snooz_fan_entity_id) + assert state.attributes[fan.ATTR_PERCENTAGE] == percentage + assert ATTR_ASSUMED_STATE not in state.attributes + + +async def test_set_0_percentage_turns_off( + hass: HomeAssistant, snooz_fan_entity_id: str +): + """Test turning off the device by setting the percentage/volume to 0.""" + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id], fan.ATTR_PERCENTAGE: 66}, + blocking=True, + ) + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: [snooz_fan_entity_id], fan.ATTR_PERCENTAGE: 0}, + blocking=True, + ) + + state = hass.states.get(snooz_fan_entity_id) + assert state.state == STATE_OFF + # doesn't overwrite percentage when turning off + assert state.attributes[fan.ATTR_PERCENTAGE] == 66 + assert ATTR_ASSUMED_STATE not in state.attributes + + +async def test_turn_off(hass: HomeAssistant, snooz_fan_entity_id: str): + """Test turning off the device.""" + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) + + state = hass.states.get(snooz_fan_entity_id) + assert state.state == STATE_OFF + assert ATTR_ASSUMED_STATE not in state.attributes + + +async def test_push_events( + hass: HomeAssistant, mock_connected_snooz: SnoozFixture, snooz_fan_entity_id: str +): + """Test state update events from snooz device.""" + mock_connected_snooz.device.trigger_state(SnoozDeviceState(False, 64)) + + state = hass.states.get(snooz_fan_entity_id) + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_PERCENTAGE] == 64 + + mock_connected_snooz.device.trigger_state(SnoozDeviceState(True, 12)) + + state = hass.states.get(snooz_fan_entity_id) + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 12 + + mock_connected_snooz.device.trigger_disconnect() + + state = hass.states.get(snooz_fan_entity_id) + assert state.attributes[ATTR_ASSUMED_STATE] is True + + +async def test_restore_state(hass: HomeAssistant): + """Tests restoring entity state.""" + device = await create_mock_snooz(connected=False, initial_state=UnknownSnoozState) + + entry = await create_mock_snooz_config_entry(hass, device) + entity_id = get_fan_entity_id(hass, device) + + # call service to store state + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id], fan.ATTR_PERCENTAGE: 33}, + blocking=True, + ) + + # unload entry + await hass.config_entries.async_unload(entry.entry_id) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + # reload entry + await create_mock_snooz_config_entry(hass, device) + + # should match last known state + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 33 + assert state.attributes[ATTR_ASSUMED_STATE] is True + + +async def test_restore_unknown_state(hass: HomeAssistant): + """Tests restoring entity state that was unknown.""" + device = await create_mock_snooz(connected=False, initial_state=UnknownSnoozState) + + entry = await create_mock_snooz_config_entry(hass, device) + entity_id = get_fan_entity_id(hass, device) + + # unload entry + await hass.config_entries.async_unload(entry.entry_id) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + # reload entry + await create_mock_snooz_config_entry(hass, device) + + # should match last known state + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + +async def test_command_results( + hass: HomeAssistant, mock_connected_snooz: SnoozFixture, snooz_fan_entity_id: str +): + """Test device command results.""" + mock_execute = Mock(spec=mock_connected_snooz.device.async_execute_command) + + mock_connected_snooz.device.async_execute_command = mock_execute + + mock_execute.return_value = SnoozCommandResult( + SnoozCommandResultStatus.SUCCESSFUL, timedelta() + ) + mock_connected_snooz.device.state = SnoozDeviceState(on=True, volume=56) + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) + + state = hass.states.get(snooz_fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 56 + + mock_execute.return_value = SnoozCommandResult( + SnoozCommandResultStatus.CANCELLED, timedelta() + ) + mock_connected_snooz.device.state = SnoozDeviceState(on=False, volume=15) + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) + + # the device state shouldn't be written when cancelled + state = hass.states.get(snooz_fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 56 + + mock_execute.return_value = SnoozCommandResult( + SnoozCommandResultStatus.UNEXPECTED_ERROR, timedelta() + ) + + with pytest.raises(HomeAssistantError) as failure: + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) + + assert failure.match("failed with status") + + +@pytest.fixture(name="snooz_fan_entity_id") +async def fixture_snooz_fan_entity_id( + hass: HomeAssistant, mock_connected_snooz: SnoozFixture +) -> str: + """Mock a Snooz fan entity and config entry.""" + + yield get_fan_entity_id(hass, mock_connected_snooz.device) + + +def get_fan_entity_id(hass: HomeAssistant, device: MockSnoozDevice) -> str: + """Get the entity ID for a mock device.""" + + return entity_registry.async_get(hass).async_get_entity_id( + Platform.FAN, DOMAIN, device.address + ) diff --git a/tests/components/sonarr/fixtures/system-status.json b/tests/components/sonarr/fixtures/system-status.json index fe6198a04445548a36ec50e14aa13628a893cf84..311cadd4ff08dbfd81b7e706d03d37c23d1f6b85 100644 --- a/tests/components/sonarr/fixtures/system-status.json +++ b/tests/components/sonarr/fixtures/system-status.json @@ -1,5 +1,6 @@ { "appName": "Sonarr", + "instanceName": "Sonarr", "version": "3.0.6.1451", "buildTime": "2022-01-23T16:51:56Z", "isDebug": false, diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index f776fb62d58d13738ec249f792a4bc109f034622..2ac1cb460cb80fadac3c2681b4fe335e5da360cb 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -135,6 +135,10 @@ async def silent_ssdp_scanner(hass): "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" + ), patch( + "homeassistant.components.ssdp.Server._async_start_upnp_servers" + ), patch( + "homeassistant.components.ssdp.Server._async_stop_upnp_servers" ): yield diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index b4186ecacef4d7e51fedc9773c6aa6a308cdc770..4b8071ad1eaada72030a4c90f2e2031810125ba8 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -1,11 +1,8 @@ """Tests for SpeedTest integration.""" -from collections.abc import Awaitable from datetime import timedelta -from typing import Callable from unittest.mock import MagicMock -from aiohttp import ClientWebSocketResponse import speedtest from homeassistant.components.speedtestdotnet.const import ( @@ -13,7 +10,6 @@ from homeassistant.components.speedtestdotnet.const import ( CONF_SERVER_ID, CONF_SERVER_NAME, DOMAIN, - SPEED_TEST_SERVICE, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_SCAN_INTERVAL, STATE_UNAVAILABLE @@ -21,7 +17,6 @@ from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -from tests.components.repairs import get_repairs async def test_successful_config_entry(hass: HomeAssistant) -> None: @@ -43,7 +38,6 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: assert entry.state == ConfigEntryState.LOADED assert hass.data[DOMAIN] - assert hass.services.has_service(DOMAIN, SPEED_TEST_SERVICE) async def test_setup_failed(hass: HomeAssistant, mock_api: MagicMock) -> None: @@ -125,28 +119,3 @@ async def test_get_best_server_error(hass: HomeAssistant, mock_api: MagicMock) - state = hass.states.get("sensor.speedtest_ping") assert state is not None assert state.state == STATE_UNAVAILABLE - - -async def test_deprecated_service_alert( - hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], -) -> None: - """Test that an issue is raised if deprecated services is called.""" - entry = MockConfigEntry( - domain=DOMAIN, - ) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - await hass.services.async_call( - DOMAIN, - "speedtest", - {}, - blocking=True, - ) - await hass.async_block_till_done() - issues = await get_repairs(hass, hass_ws_client) - assert len(issues) == 1 - assert issues[0]["issue_id"] == "deprecated_service" diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index ea54745048e3c50a4a8c57990fc08b1eddd6d8bd..e5bbc163249b582179153451a976f264642615e7 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -21,7 +21,7 @@ from . import ( from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, recorder_mock) -> None: +async def test_form(recorder_mock, hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -53,7 +53,7 @@ async def test_form(hass: HomeAssistant, recorder_mock) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_flow_success(hass: HomeAssistant, recorder_mock) -> None: +async def test_import_flow_success(recorder_mock, hass: HomeAssistant) -> None: """Test a successful import of yaml.""" with patch( @@ -80,7 +80,7 @@ async def test_import_flow_success(hass: HomeAssistant, recorder_mock) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_flow_already_exist(hass: HomeAssistant, recorder_mock) -> None: +async def test_import_flow_already_exist(recorder_mock, hass: HomeAssistant) -> None: """Test import of yaml already exist.""" MockConfigEntry( @@ -103,7 +103,7 @@ async def test_import_flow_already_exist(hass: HomeAssistant, recorder_mock) -> assert result3["reason"] == "already_configured" -async def test_flow_fails_db_url(hass: HomeAssistant, recorder_mock) -> None: +async def test_flow_fails_db_url(recorder_mock, hass: HomeAssistant) -> None: """Test config flow fails incorrect db url.""" result4 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -124,7 +124,7 @@ async def test_flow_fails_db_url(hass: HomeAssistant, recorder_mock) -> None: assert result4["errors"] == {"db_url": "db_url_invalid"} -async def test_flow_fails_invalid_query(hass: HomeAssistant, recorder_mock) -> None: +async def test_flow_fails_invalid_query(recorder_mock, hass: HomeAssistant) -> None: """Test config flow fails incorrect db url.""" result4 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -170,7 +170,7 @@ async def test_flow_fails_invalid_query(hass: HomeAssistant, recorder_mock) -> N } -async def test_options_flow(hass: HomeAssistant, recorder_mock) -> None: +async def test_options_flow(recorder_mock, hass: HomeAssistant) -> None: """Test options config flow.""" entry = MockConfigEntry( domain=DOMAIN, @@ -219,7 +219,7 @@ async def test_options_flow(hass: HomeAssistant, recorder_mock) -> None: async def test_options_flow_name_previously_removed( - hass: HomeAssistant, recorder_mock + recorder_mock, hass: HomeAssistant ) -> None: """Test options config flow where the name was missing.""" entry = MockConfigEntry( @@ -270,7 +270,7 @@ async def test_options_flow_name_previously_removed( } -async def test_options_flow_fails_db_url(hass: HomeAssistant, recorder_mock) -> None: +async def test_options_flow_fails_db_url(recorder_mock, hass: HomeAssistant) -> None: """Test options flow fails incorrect db url.""" entry = MockConfigEntry( domain=DOMAIN, @@ -313,7 +313,7 @@ async def test_options_flow_fails_db_url(hass: HomeAssistant, recorder_mock) -> async def test_options_flow_fails_invalid_query( - hass: HomeAssistant, recorder_mock + recorder_mock, hass: HomeAssistant ) -> None: """Test options flow fails incorrect query and template.""" entry = MockConfigEntry( @@ -369,7 +369,7 @@ async def test_options_flow_fails_invalid_query( } -async def test_options_flow_db_url_empty(hass: HomeAssistant, recorder_mock) -> None: +async def test_options_flow_db_url_empty(recorder_mock, hass: HomeAssistant) -> None: """Test options config flow with leaving db_url empty.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index cb19ae8720a3eb282da9286c690349a2383908fc..3cf2837353e5fb201b6f9d51c24373529c56c23c 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.srp_energy.const import ( SRP_ENERGY_DOMAIN, ) from homeassistant.components.srp_energy.sensor import SrpEntity, async_setup_entry -from homeassistant.const import ATTR_ATTRIBUTION, ENERGY_KILO_WATT_HOUR +from homeassistant.const import ENERGY_KILO_WATT_HOUR async def test_async_setup_entry(hass): @@ -93,7 +93,7 @@ async def test_srp_entity(hass): assert srp_entity.icon == ICON assert srp_entity.usage == "2.00" assert srp_entity.should_poll is False - assert srp_entity.extra_state_attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert srp_entity.attribution == ATTRIBUTION assert srp_entity.available is not None assert srp_entity.device_class is SensorDeviceClass.ENERGY assert srp_entity.state_class is SensorStateClass.TOTAL_INCREASING diff --git a/tests/components/ssdp/conftest.py b/tests/components/ssdp/conftest.py index 0b390ae469b3f185ebd8ad5224bf60d100f1c959..7b6d67895e57ece384252a83e796f7df55148eb8 100644 --- a/tests/components/ssdp/conftest.py +++ b/tests/components/ssdp/conftest.py @@ -1,6 +1,7 @@ """Configuration for SSDP tests.""" from unittest.mock import AsyncMock, patch +from async_upnp_client.server import UpnpServer from async_upnp_client.ssdp_listener import SsdpListener import pytest @@ -16,6 +17,15 @@ async def silent_ssdp_listener(): yield SsdpListener +@pytest.fixture(autouse=True) +async def disabled_upnp_server(): + """Disable UPnpServer.""" + with patch("homeassistant.components.ssdp.UpnpServer.async_start"), patch( + "homeassistant.components.ssdp.UpnpServer.async_stop" + ), patch("homeassistant.components.ssdp._async_find_next_available_port"): + yield UpnpServer + + @pytest.fixture def mock_flow_init(hass): """Mock hass.config_entries.flow.async_init.""" diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index bf88f45acf90cac38be92a56f0e119c28262aae0..4076ef4685d20bf2ece79aaae03a3e5a7b138045 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from ipaddress import IPv4Address from unittest.mock import ANY, AsyncMock, patch +from async_upnp_client.server import UpnpServer from async_upnp_client.ssdp import udn_from_headers from async_upnp_client.ssdp_listener import SsdpListener from async_upnp_client.utils import CaseInsensitiveDict @@ -34,7 +35,7 @@ async def init_ssdp_component(hass: homeassistant) -> SsdpListener: """Initialize ssdp component and get SsdpListener.""" await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) await hass.async_block_till_done() - return hass.data[ssdp.DOMAIN]._ssdp_listeners[0] + return hass.data[ssdp.DOMAIN][ssdp.SSDP_SCANNER]._ssdp_listeners[0] @patch( @@ -407,7 +408,7 @@ async def test_discovery_from_advertisement_sets_ssdp_st( @patch( - "homeassistant.components.ssdp.Scanner._async_build_source_set", + "homeassistant.components.ssdp.async_build_source_set", return_value={IPv4Address("192.168.1.1")}, ) @pytest.mark.usefixtures("mock_get_source_ip") @@ -668,9 +669,9 @@ async def test_async_detect_interfaces_setting_empty_route( """Test without default interface config and the route returns nothing.""" await init_ssdp_component(hass) - ssdp_listeners = hass.data[ssdp.DOMAIN]._ssdp_listeners + ssdp_listeners = hass.data[ssdp.DOMAIN][ssdp.SSDP_SCANNER]._ssdp_listeners sources = {ssdp_listener.source for ssdp_listener in ssdp_listeners} - assert sources == {("2001:db8::%1", 0, 0, 1), ("192.168.1.5", 0)} + assert sources == {("2001:db8::", 0, 0, 1), ("192.168.1.5", 0)} @pytest.mark.usefixtures("mock_get_source_ip") @@ -694,18 +695,29 @@ async def test_bind_failure_skips_adapter( """Test that an adapter with a bind failure is skipped.""" async def _async_start(self): - if self.source == ("2001:db8::%1", 0, 0, 1): + if self.source == ("2001:db8::", 0, 0, 1): raise OSError SsdpListener.async_start = _async_start + UpnpServer.async_start = _async_start await init_ssdp_component(hass) assert "Failed to setup listener for" in caplog.text - ssdp_listeners = hass.data[ssdp.DOMAIN]._ssdp_listeners + ssdp_listeners: list[SsdpListener] = hass.data[ssdp.DOMAIN][ + ssdp.SSDP_SCANNER + ]._ssdp_listeners sources = {ssdp_listener.source for ssdp_listener in ssdp_listeners} assert sources == {("192.168.1.5", 0)} # Note no SsdpListener for IPv6 address. + assert "Failed to setup server for" in caplog.text + + upnp_servers: list[UpnpServer] = hass.data[ssdp.DOMAIN][ + ssdp.UPNP_SERVER + ]._upnp_servers + sources = {upnp_server.source for upnp_server in upnp_servers} + assert sources == {("192.168.1.5", 0)} # Note no UpnpServer for IPv6 address. + @pytest.mark.usefixtures("mock_get_source_ip") @patch( diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 56255216f528c2d9b999409f1f7e6e2a298218d7..691159fe2fcf72ab09c1c95dc398beb13a0a8535 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -769,6 +769,56 @@ async def test_state_characteristics(hass: HomeAssistant): "value_9": float(round(statistics.stdev(VALUES_NUMERIC), 2)), "unit": "°C", }, + { + "source_sensor_domain": "sensor", + "name": "sum", + "value_0": STATE_UNKNOWN, + "value_1": float(VALUES_NUMERIC[-1]), + "value_9": float(sum(VALUES_NUMERIC)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "sum_differences", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float( + sum( + [ + abs(20 - 17), + abs(15.2 - 20), + abs(5 - 15.2), + abs(3.8 - 5), + abs(9.2 - 3.8), + abs(6.7 - 9.2), + abs(14 - 6.7), + abs(6 - 14), + ] + ) + ), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "sum_differences_nonnegative", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float( + sum( + [ + 20 - 17, + 15.2 - 0, + 5 - 0, + 3.8 - 0, + 9.2 - 3.8, + 6.7 - 0, + 14 - 6.7, + 6 - 0, + ] + ) + ), + "unit": "°C", + }, { "source_sensor_domain": "sensor", "name": "total", @@ -1010,7 +1060,7 @@ async def test_invalid_state_characteristic(hass: HomeAssistant): assert state is None -async def test_initialize_from_database(hass: HomeAssistant, recorder_mock): +async def test_initialize_from_database(recorder_mock, hass: HomeAssistant): """Test initializing the statistics from the recorder database.""" # enable and pre-fill the recorder await hass.async_block_till_done() @@ -1049,7 +1099,7 @@ async def test_initialize_from_database(hass: HomeAssistant, recorder_mock): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS -async def test_initialize_from_database_with_maxage(hass: HomeAssistant, recorder_mock): +async def test_initialize_from_database_with_maxage(recorder_mock, hass: HomeAssistant): """Test initializing the statistics from the database.""" now = dt_util.utcnow() mock_data = { @@ -1109,7 +1159,7 @@ async def test_initialize_from_database_with_maxage(hass: HomeAssistant, recorde ) + timedelta(hours=1) -async def test_reload(hass: HomeAssistant, recorder_mock): +async def test_reload(recorder_mock, hass: HomeAssistant): """Verify we can reload statistics sensors.""" await async_setup_component( diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 54400af65ab519318938619c7f4500361369a9b2..e77b062fa9c8203fd7624bb6d73ffec124b691f3 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -794,7 +794,7 @@ async def test_durations(hass, worker_finished_stream): assert math.isclose( (av_part.duration - av_part.start_time) / av.time_base, part.duration, - abs_tol=2 / av_part.streams.video[0].rate + 1e-6, + abs_tol=2 / av_part.streams.video[0].average_rate + 1e-6, ) # Also check that the sum of the durations so far matches the last dts # in the media. diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py index b6a79ab8829500f431fa2109f244704fd9850406..bd107f4bb3776cbd7227d2a63550bc70cfbad1ea 100644 --- a/tests/components/subaru/api_responses.py +++ b/tests/components/subaru/api_responses.py @@ -1,5 +1,7 @@ """Sample API response data for tests.""" +from datetime import datetime, timezone + from homeassistant.components.subaru.const import ( API_GEN_1, API_GEN_2, @@ -46,10 +48,12 @@ VEHICLE_DATA = { }, } +MOCK_DATETIME = datetime.fromtimestamp(1595560000, timezone.utc) + VEHICLE_STATUS_EV = { "status": { "AVG_FUEL_CONSUMPTION": 2.3, - "BATTERY_VOLTAGE": "12.0", + "BATTERY_VOLTAGE": 12.0, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", "DOOR_BOOT_POSITION": "CLOSED", @@ -63,21 +67,17 @@ VEHICLE_STATUS_EV = { "DOOR_REAR_LEFT_POSITION": "CLOSED", "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_RIGHT_POSITION": "CLOSED", - "EV_CHARGER_STATE_TYPE": "CHARGING_STOPPED", + "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", - "EV_DISTANCE_TO_EMPTY": 17, + "EV_DISTANCE_TO_EMPTY": 1, "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", "EV_STATE_OF_CHARGE_MODE": "EV_MODE", - "EV_STATE_OF_CHARGE_PERCENT": "100", - "EV_TIME_TO_FULLY_CHARGED": "65535", - "EV_VEHICLE_TIME_DAYOFWEEK": "6", - "EV_VEHICLE_TIME_HOUR": "14", - "EV_VEHICLE_TIME_MINUTE": "20", - "EV_VEHICLE_TIME_SECOND": "39", - "EXT_EXTERNAL_TEMP": "21.5", + "EV_STATE_OF_CHARGE_PERCENT": 20, + "EV_TIME_TO_FULLY_CHARGED_UTC": MOCK_DATETIME, + "EXT_EXTERNAL_TEMP": 21.5, "ODOMETER": 1234, - "POSITION_HEADING_DEGREE": "150", + "POSITION_HEADING_DEGREE": 150, "POSITION_SPEED_KMPH": "0", "POSITION_TIMESTAMP": 1595560000.0, "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", @@ -100,7 +100,7 @@ VEHICLE_STATUS_EV = { "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": 2550, + "TYRE_PRESSURE_FRONT_LEFT": 0, "TYRE_PRESSURE_FRONT_RIGHT": 2550, "TYRE_PRESSURE_REAR_LEFT": 2450, "TYRE_PRESSURE_REAR_RIGHT": 2350, @@ -121,10 +121,11 @@ VEHICLE_STATUS_EV = { } } + VEHICLE_STATUS_G2 = { "status": { "AVG_FUEL_CONSUMPTION": 2.3, - "BATTERY_VOLTAGE": "12.0", + "BATTERY_VOLTAGE": 12.0, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", "DOOR_BOOT_POSITION": "CLOSED", @@ -138,9 +139,9 @@ VEHICLE_STATUS_G2 = { "DOOR_REAR_LEFT_POSITION": "CLOSED", "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_RIGHT_POSITION": "CLOSED", - "EXT_EXTERNAL_TEMP": "21.5", + "EXT_EXTERNAL_TEMP": None, "ODOMETER": 1234, - "POSITION_HEADING_DEGREE": "150", + "POSITION_HEADING_DEGREE": 150, "POSITION_SPEED_KMPH": "0", "POSITION_TIMESTAMP": 1595560000.0, "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", @@ -188,18 +189,14 @@ EXPECTED_STATE_EV_IMPERIAL = { "AVG_FUEL_CONSUMPTION": "102.3", "BATTERY_VOLTAGE": "12.0", "DISTANCE_TO_EMPTY_FUEL": "439.3", - "EV_CHARGER_STATE_TYPE": "CHARGING_STOPPED", + "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", - "EV_DISTANCE_TO_EMPTY": "17", + "EV_DISTANCE_TO_EMPTY": "1", "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", "EV_STATE_OF_CHARGE_MODE": "EV_MODE", - "EV_STATE_OF_CHARGE_PERCENT": "100", - "EV_TIME_TO_FULLY_CHARGED": "unknown", - "EV_VEHICLE_TIME_DAYOFWEEK": "6", - "EV_VEHICLE_TIME_HOUR": "14", - "EV_VEHICLE_TIME_MINUTE": "20", - "EV_VEHICLE_TIME_SECOND": "39", + "EV_STATE_OF_CHARGE_PERCENT": "20", + "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", "EXT_EXTERNAL_TEMP": "70.7", "ODOMETER": "766.8", "POSITION_HEADING_DEGREE": "150", @@ -207,7 +204,7 @@ EXPECTED_STATE_EV_IMPERIAL = { "POSITION_TIMESTAMP": 1595560000.0, "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": "37.0", + "TYRE_PRESSURE_FRONT_LEFT": "0.0", "TYRE_PRESSURE_FRONT_RIGHT": "37.0", "TYRE_PRESSURE_REAR_LEFT": "35.5", "TYRE_PRESSURE_REAR_RIGHT": "34.1", @@ -221,18 +218,14 @@ EXPECTED_STATE_EV_METRIC = { "AVG_FUEL_CONSUMPTION": "2.3", "BATTERY_VOLTAGE": "12.0", "DISTANCE_TO_EMPTY_FUEL": "707", - "EV_CHARGER_STATE_TYPE": "CHARGING_STOPPED", + "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", - "EV_DISTANCE_TO_EMPTY": "27.4", + "EV_DISTANCE_TO_EMPTY": "1.6", "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", "EV_STATE_OF_CHARGE_MODE": "EV_MODE", - "EV_STATE_OF_CHARGE_PERCENT": "100", - "EV_TIME_TO_FULLY_CHARGED": "unknown", - "EV_VEHICLE_TIME_DAYOFWEEK": "6", - "EV_VEHICLE_TIME_HOUR": "14", - "EV_VEHICLE_TIME_MINUTE": "20", - "EV_VEHICLE_TIME_SECOND": "39", + "EV_STATE_OF_CHARGE_PERCENT": "20", + "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", "EXT_EXTERNAL_TEMP": "21.5", "ODOMETER": "1234", "POSITION_HEADING_DEGREE": "150", @@ -240,7 +233,7 @@ EXPECTED_STATE_EV_METRIC = { "POSITION_TIMESTAMP": 1595560000.0, "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": "2550", + "TYRE_PRESSURE_FRONT_LEFT": "0", "TYRE_PRESSURE_FRONT_RIGHT": "2550", "TYRE_PRESSURE_REAR_LEFT": "2450", "TYRE_PRESSURE_REAR_RIGHT": "2350", @@ -250,6 +243,7 @@ EXPECTED_STATE_EV_METRIC = { "longitude": -100.0, } + EXPECTED_STATE_EV_UNAVAILABLE = { "AVG_FUEL_CONSUMPTION": "unavailable", "BATTERY_VOLTAGE": "unavailable", @@ -261,11 +255,7 @@ EXPECTED_STATE_EV_UNAVAILABLE = { "EV_IS_PLUGGED_IN": "unavailable", "EV_STATE_OF_CHARGE_MODE": "unavailable", "EV_STATE_OF_CHARGE_PERCENT": "unavailable", - "EV_TIME_TO_FULLY_CHARGED": "unavailable", - "EV_VEHICLE_TIME_DAYOFWEEK": "unavailable", - "EV_VEHICLE_TIME_HOUR": "unavailable", - "EV_VEHICLE_TIME_MINUTE": "unavailable", - "EV_VEHICLE_TIME_SECOND": "unavailable", + "EV_TIME_TO_FULLY_CHARGED_UTC": "unavailable", "EXT_EXTERNAL_TEMP": "unavailable", "ODOMETER": "unavailable", "POSITION_HEADING_DEGREE": "unavailable", diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index 53bd04e7e55dff44206843cbc143c913f1ac5585..20d70a7d496f9a3737f51860621a62504c9bcfeb 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest from subarulink.const import COUNTRY_USA +from homeassistant import config_entries from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.components.subaru.const import ( CONF_COUNTRY, @@ -71,7 +72,17 @@ TEST_OPTIONS = { CONF_UPDATE_ENABLED: True, } -TEST_ENTITY_ID = "sensor.test_vehicle_2_odometer" +TEST_CONFIG_ENTRY = { + "entry_id": "1", + "domain": DOMAIN, + "title": TEST_CONFIG[CONF_USERNAME], + "data": TEST_CONFIG, + "options": TEST_OPTIONS, + "source": config_entries.SOURCE_USER, +} + +TEST_DEVICE_NAME = "test_vehicle_2" +TEST_ENTITY_ID = f"sensor.{TEST_DEVICE_NAME}_odometer" def advance_time_to_next_fetch(hass): @@ -80,26 +91,16 @@ def advance_time_to_next_fetch(hass): async_fire_time_changed(hass, future) -async def setup_subaru_integration( +async def setup_subaru_config_entry( hass, - vehicle_list=None, - vehicle_data=None, - vehicle_status=None, + config_entry, + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=VEHICLE_STATUS_EV, connect_effect=None, fetch_effect=None, ): - """Create Subaru entry.""" - assert await async_setup_component(hass, HA_DOMAIN, {}) - assert await async_setup_component(hass, DOMAIN, {}) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=TEST_CONFIG, - options=TEST_OPTIONS, - entry_id=1, - ) - config_entry.add_to_hass(hass) - + """Run async_setup with API mocks in place.""" with patch( MOCK_API_CONNECT, return_value=connect_effect is None, @@ -133,20 +134,22 @@ async def setup_subaru_integration( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + +@pytest.fixture +async def subaru_config_entry(hass): + """Create a Subaru config entry prior to setup.""" + await async_setup_component(hass, HA_DOMAIN, {}) + config_entry = MockConfigEntry(**TEST_CONFIG_ENTRY) + config_entry.add_to_hass(hass) return config_entry @pytest.fixture -async def ev_entry(hass): +async def ev_entry(hass, subaru_config_entry): """Create a Subaru entry representing an EV vehicle with full STARLINK subscription.""" - entry = await setup_subaru_integration( - hass, - vehicle_list=[TEST_VIN_2_EV], - vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], - vehicle_status=VEHICLE_STATUS_EV, - ) + await setup_subaru_config_entry(hass, subaru_config_entry) assert DOMAIN in hass.config_entries.async_domains() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert hass.config_entries.async_get_entry(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED - return entry + assert hass.config_entries.async_get_entry(subaru_config_entry.entry_id) + assert subaru_config_entry.state is ConfigEntryState.LOADED + return subaru_config_entry diff --git a/tests/components/subaru/test_init.py b/tests/components/subaru/test_init.py index cd87ed40315d3c6abf737ad2f135a8ada4cf1bd9..46a8b2e103b3b154c4ae6aeac2628bc96c86a7e2 100644 --- a/tests/components/subaru/test_init.py +++ b/tests/components/subaru/test_init.py @@ -24,7 +24,7 @@ from .conftest import ( MOCK_API_FETCH, MOCK_API_UPDATE, TEST_ENTITY_ID, - setup_subaru_integration, + setup_subaru_config_entry, ) @@ -42,61 +42,70 @@ async def test_setup_ev(hass, ev_entry): assert check_entry.state is ConfigEntryState.LOADED -async def test_setup_g2(hass): +async def test_setup_g2(hass, subaru_config_entry): """Test setup with a G2 vehcile .""" - entry = await setup_subaru_integration( + await setup_subaru_config_entry( hass, + subaru_config_entry, vehicle_list=[TEST_VIN_3_G2], vehicle_data=VEHICLE_DATA[TEST_VIN_3_G2], vehicle_status=VEHICLE_STATUS_G2, ) - check_entry = hass.config_entries.async_get_entry(entry.entry_id) + check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry assert check_entry.state is ConfigEntryState.LOADED -async def test_setup_g1(hass): +async def test_setup_g1(hass, subaru_config_entry): """Test setup with a G1 vehicle.""" - entry = await setup_subaru_integration( - hass, vehicle_list=[TEST_VIN_1_G1], vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1] + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[TEST_VIN_1_G1], + vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1], ) - check_entry = hass.config_entries.async_get_entry(entry.entry_id) + check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry assert check_entry.state is ConfigEntryState.LOADED -async def test_unsuccessful_connect(hass): +async def test_unsuccessful_connect(hass, subaru_config_entry): """Test unsuccessful connect due to connectivity.""" - entry = await setup_subaru_integration( + await setup_subaru_config_entry( hass, + subaru_config_entry, connect_effect=SubaruException("Service Unavailable"), vehicle_list=[TEST_VIN_2_EV], vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], vehicle_status=VEHICLE_STATUS_EV, ) - check_entry = hass.config_entries.async_get_entry(entry.entry_id) + check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry assert check_entry.state is ConfigEntryState.SETUP_RETRY -async def test_invalid_credentials(hass): +async def test_invalid_credentials(hass, subaru_config_entry): """Test invalid credentials.""" - entry = await setup_subaru_integration( + await setup_subaru_config_entry( hass, + subaru_config_entry, connect_effect=InvalidCredentials("Invalid Credentials"), vehicle_list=[TEST_VIN_2_EV], vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], vehicle_status=VEHICLE_STATUS_EV, ) - check_entry = hass.config_entries.async_get_entry(entry.entry_id) + check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry assert check_entry.state is ConfigEntryState.SETUP_ERROR -async def test_update_skip_unsubscribed(hass): +async def test_update_skip_unsubscribed(hass, subaru_config_entry): """Test update function skips vehicles without subscription.""" - await setup_subaru_integration( - hass, vehicle_list=[TEST_VIN_1_G1], vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1] + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[TEST_VIN_1_G1], + vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1], ) with patch(MOCK_API_FETCH) as mock_fetch: @@ -126,10 +135,11 @@ async def test_update_disabled(hass, ev_entry): mock_update.assert_not_called() -async def test_fetch_failed(hass): +async def test_fetch_failed(hass, subaru_config_entry): """Tests when fetch fails.""" - await setup_subaru_integration( + await setup_subaru_config_entry( hass, + subaru_config_entry, vehicle_list=[TEST_VIN_2_EV], vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], vehicle_status=VEHICLE_STATUS_EV, diff --git a/tests/components/subaru/test_sensor.py b/tests/components/subaru/test_sensor.py index 6ad5e729290390fbdf8c9996ae494d905582caf4..caec43d36e8ab15994143f5c449cd599a8917b93 100644 --- a/tests/components/subaru/test_sensor.py +++ b/tests/components/subaru/test_sensor.py @@ -1,33 +1,38 @@ """Test Subaru sensors.""" from unittest.mock import patch -from homeassistant.components.subaru.const import VEHICLE_NAME +import pytest + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.subaru.sensor import ( API_GEN_2_SENSORS, + DOMAIN as SUBARU_DOMAIN, EV_SENSORS, SAFETY_SENSORS, - SENSOR_FIELD, - SENSOR_TYPE, ) +from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .api_responses import ( EXPECTED_STATE_EV_IMPERIAL, EXPECTED_STATE_EV_METRIC, EXPECTED_STATE_EV_UNAVAILABLE, TEST_VIN_2_EV, - VEHICLE_DATA, VEHICLE_STATUS_EV, ) -from .conftest import MOCK_API_FETCH, MOCK_API_GET_DATA, advance_time_to_next_fetch - -VEHICLE_NAME = VEHICLE_DATA[TEST_VIN_2_EV][VEHICLE_NAME] +from .conftest import ( + MOCK_API_FETCH, + MOCK_API_GET_DATA, + TEST_DEVICE_NAME, + advance_time_to_next_fetch, + setup_subaru_config_entry, +) async def test_sensors_ev_imperial(hass, ev_entry): """Test sensors supporting imperial units.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM with patch(MOCK_API_FETCH), patch( MOCK_API_GET_DATA, return_value=VEHICLE_STATUS_EV @@ -52,6 +57,84 @@ async def test_sensors_missing_vin_data(hass, ev_entry): _assert_data(hass, EXPECTED_STATE_EV_UNAVAILABLE) +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": SENSOR_DOMAIN, + "platform": SUBARU_DOMAIN, + "unique_id": f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + }, + f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].key}", + ), + ], +) +async def test_sensor_migrate_unique_ids( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test successful migration of entity unique_ids.""" + entity_registry = er.async_get(hass) + entity: er.RegistryEntry = entity_registry.async_get_or_create( + **entitydata, + config_entry=subaru_config_entry, + ) + assert entity.unique_id == old_unique_id + + await setup_subaru_config_entry(hass, subaru_config_entry) + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == new_unique_id + + +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": SENSOR_DOMAIN, + "platform": SUBARU_DOMAIN, + "unique_id": f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + }, + f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].key}", + ) + ], +) +async def test_sensor_migrate_unique_ids_duplicate( + hass, entitydata, old_unique_id, new_unique_id, subaru_config_entry +) -> None: + """Test unsuccessful migration of entity unique_ids due to duplicate.""" + entity_registry = er.async_get(hass) + entity: er.RegistryEntry = entity_registry.async_get_or_create( + **entitydata, + config_entry=subaru_config_entry, + ) + assert entity.unique_id == old_unique_id + + # create existing entry with new_unique_id that conflicts with migrate + existing_entity = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + SUBARU_DOMAIN, + unique_id=new_unique_id, + config_entry=subaru_config_entry, + ) + + await setup_subaru_config_entry(hass, subaru_config_entry) + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == old_unique_id + + entity_not_changed = entity_registry.async_get(existing_entity.entity_id) + assert entity_not_changed + assert entity_not_changed.unique_id == new_unique_id + + assert entity_migrated != entity_not_changed + + def _assert_data(hass, expected_state): sensor_list = EV_SENSORS sensor_list.extend(API_GEN_2_SENSORS) @@ -59,9 +142,9 @@ def _assert_data(hass, expected_state): expected_states = {} for item in sensor_list: expected_states[ - f"sensor.{slugify(f'{VEHICLE_NAME} {item[SENSOR_TYPE]}')}" - ] = expected_state[item[SENSOR_FIELD]] + f"sensor.{slugify(f'{TEST_DEVICE_NAME} {item.name}')}" + ] = expected_state[item.key] - for sensor in expected_states: + for sensor, value in expected_states.items(): actual = hass.states.get(sensor) - assert actual.state == expected_states[sensor] + assert actual.state == value diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index 13aa6d487919fd4db2bf968aaa177a3fe80c2744..6f0f26f5f7ab07ae96201e5b88563f2b917bec32 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -18,9 +18,7 @@ async def test_setting_rising(hass): """Test retrieving sun setting and rising.""" utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) with freeze_time(utc_now): - await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) await hass.async_block_till_done() state = hass.states.get(sun.ENTITY_ID) @@ -112,9 +110,7 @@ async def test_state_change(hass, caplog): """Test if the state changes at next setting/rising.""" now = datetime(2016, 6, 1, 8, 0, 0, tzinfo=dt_util.UTC) with freeze_time(now): - await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) await hass.async_block_till_done() @@ -167,9 +163,7 @@ async def test_norway_in_june(hass): june = datetime(2016, 6, 1, tzinfo=dt_util.UTC) with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=june): - assert await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) state = hass.states.get(sun.ENTITY_ID) assert state is not None @@ -195,9 +189,7 @@ async def test_state_change_count(hass): now = datetime(2016, 6, 1, tzinfo=dt_util.UTC) with freeze_time(now): - assert await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) events = [] diff --git a/tests/components/sun/test_recorder.py b/tests/components/sun/test_recorder.py index 547bf44ec5f94e3b866bedaad8aa54429bc79b19..d8766beaa2b51da9cd945b8da19f98c2d4b8e282 100644 --- a/tests/components/sun/test_recorder.py +++ b/tests/components/sun/test_recorder.py @@ -26,7 +26,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test sun attributes to be excluded.""" await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 5f2275dca343d26f3fe40bfa41d18e57b3d3850d..a2f0fca8b7e76877e73db1f02bf76f551cb5a84a 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -32,7 +32,7 @@ def setup_comp(hass): """Initialize components.""" mock_component(hass, "group") hass.loop.run_until_complete( - async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) ) diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index f30f72892ba8347d5d37e2255c2a8876fe90d494..d5216cf62622901b5d4b32c075559c4b3d30f1ec 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -2,13 +2,13 @@ from unittest.mock import patch from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +from tests.components.bluetooth import generate_advertisement_data DOMAIN = "switchbot" @@ -62,7 +62,7 @@ WOHAND_SERVICE_INFO = BluetoothServiceInfoBleak( address="AA:BB:CC:DD:EE:FF", rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( local_name="WoHand", manufacturer_data={89: b"\xfd`0U\x92W"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"}, @@ -82,7 +82,7 @@ WOHAND_SERVICE_INFO_NOT_CONNECTABLE = BluetoothServiceInfoBleak( address="aa:bb:cc:dd:ee:ff", rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( local_name="WoHand", manufacturer_data={89: b"\xfd`0U\x92W"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"}, @@ -102,7 +102,7 @@ WOHAND_ENCRYPTED_SERVICE_INFO = BluetoothServiceInfoBleak( address="798A8547-2A3D-C609-55FF-73FA824B923B", rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( local_name="WoHand", manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"\xc8\x10\xcf"}, @@ -122,7 +122,7 @@ WOHAND_SERVICE_ALT_ADDRESS_INFO = BluetoothServiceInfoBleak( address="cc:cc:cc:cc:cc:cc", rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( local_name="WoHand", manufacturer_data={89: b"\xfd`0U\x92W"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"}, @@ -140,7 +140,7 @@ WOCURTAIN_SERVICE_INFO = BluetoothServiceInfoBleak( service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( local_name="WoCurtain", manufacturer_data={89: b"\xc1\xc7'}U\xab"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0Y\x00\x11\x04"}, @@ -159,7 +159,7 @@ WOSENSORTH_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00d\x00\x96\xac"}, rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( manufacturer_data={2409: b"\xda,\x1e\xb1\x86Au\x03\x00\x96\xac"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00d\x00\x96\xac"}, ), @@ -176,7 +176,7 @@ NOT_SWITCHBOT_INFO = BluetoothServiceInfoBleak( service_data={}, rssi=-60, source="local", - advertisement=AdvertisementData( + advertisement=generate_advertisement_data( manufacturer_data={}, service_data={}, ), diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index ae77c5a8de4074fc864f68c0a61f111b95c92c3b..9de1403a6342ae131c4fa4e1b3bdeb1e0a62e0d7 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -48,10 +48,12 @@ async def test_sensors(hass, entity_registry_enabled_by_default): assert battery_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" assert battery_sensor_attrs[ATTR_STATE_CLASS] == "measurement" - rssi_sensor = hass.states.get("sensor.test_name_rssi") + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal_strength") rssi_sensor_attrs = rssi_sensor.attributes assert rssi_sensor.state == "-60" - assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Rssi" + assert ( + rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal strength" + ) assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" assert await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index 3578e3ac6c9c4066df93ae74e68d61373796bda1..7fff1c476fb5dd6fbcd4619cf98240cef9b21832 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -43,11 +43,19 @@ def mock_api(): patchers = [ patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.connect", + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.connect", new=api_mock, ), patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.disconnect", + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.disconnect", + new=api_mock, + ), + patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.connect", + new=api_mock, + ), + patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.disconnect", new=api_mock, ), ] diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index e200d92e026045aa4fd74cb44e1e9c4023eff5be..eaf6a69cb3db750f6ca20d8790fbd2c8bd0f955c 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -3,8 +3,14 @@ from aioswitcher.device import ( DeviceState, DeviceType, + ShutterDirection, SwitcherPowerPlug, + SwitcherShutter, + SwitcherThermostat, SwitcherWaterHeater, + ThermostatFanLevel, + ThermostatMode, + ThermostatSwing, ) from homeassistant.components.switcher_kis import ( @@ -18,20 +24,36 @@ DUMMY_AUTO_OFF_SET = "01:30:00" DUMMY_AUTO_SHUT_DOWN = "02:00:00" DUMMY_DEVICE_ID1 = "a123bc" DUMMY_DEVICE_ID2 = "cafe12" +DUMMY_DEVICE_ID3 = "bada77" +DUMMY_DEVICE_ID4 = "bbd164" DUMMY_DEVICE_NAME1 = "Plug 23BC" DUMMY_DEVICE_NAME2 = "Heater FE12" +DUMMY_DEVICE_NAME3 = "Breeze AB39" +DUMMY_DEVICE_NAME4 = "Runner DD77" DUMMY_DEVICE_PASSWORD = "12345678" DUMMY_ELECTRIC_CURRENT1 = 0.5 DUMMY_ELECTRIC_CURRENT2 = 12.8 DUMMY_IP_ADDRESS1 = "192.168.100.157" DUMMY_IP_ADDRESS2 = "192.168.100.158" +DUMMY_IP_ADDRESS3 = "192.168.100.159" +DUMMY_IP_ADDRESS4 = "192.168.100.160" DUMMY_MAC_ADDRESS1 = "A1:B2:C3:45:67:D8" DUMMY_MAC_ADDRESS2 = "A1:B2:C3:45:67:D9" +DUMMY_MAC_ADDRESS3 = "A1:B2:C3:45:67:DA" +DUMMY_MAC_ADDRESS4 = "A1:B2:C3:45:67:DB" DUMMY_PHONE_ID = "1234" DUMMY_POWER_CONSUMPTION1 = 100 DUMMY_POWER_CONSUMPTION2 = 2780 DUMMY_REMAINING_TIME = "01:29:32" DUMMY_TIMER_MINUTES_SET = "90" +DUMMY_THERMOSTAT_MODE = ThermostatMode.COOL +DUMMY_TEMPERATURE = 24.1 +DUMMY_TARGET_TEMPERATURE = 23 +DUMMY_FAN_LEVEL = ThermostatFanLevel.LOW +DUMMY_SWING = ThermostatSwing.OFF +DUMMY_REMOTE_ID = "ELEC7001" +DUMMY_POSITION = 54 +DUMMY_DIRECTION = ShutterDirection.SHUTTER_STOP YAML_CONFIG = { DOMAIN: { @@ -65,4 +87,30 @@ DUMMY_WATER_HEATER_DEVICE = SwitcherWaterHeater( DUMMY_AUTO_SHUT_DOWN, ) +DUMMY_SHUTTER_DEVICE = SwitcherShutter( + DeviceType.RUNNER, + DeviceState.ON, + DUMMY_DEVICE_ID4, + DUMMY_IP_ADDRESS4, + DUMMY_MAC_ADDRESS4, + DUMMY_DEVICE_NAME4, + DUMMY_POSITION, + DUMMY_DIRECTION, +) + +DUMMY_THERMOSTAT_DEVICE = SwitcherThermostat( + DeviceType.BREEZE, + DeviceState.ON, + DUMMY_DEVICE_ID3, + DUMMY_IP_ADDRESS3, + DUMMY_MAC_ADDRESS3, + DUMMY_DEVICE_NAME3, + DUMMY_THERMOSTAT_MODE, + DUMMY_TEMPERATURE, + DUMMY_TARGET_TEMPERATURE, + DUMMY_FAN_LEVEL, + DUMMY_SWING, + DUMMY_REMOTE_ID, +) + DUMMY_SWITCHER_DEVICES = [DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE] diff --git a/tests/components/switcher_kis/test_climate.py b/tests/components/switcher_kis/test_climate.py new file mode 100644 index 0000000000000000000000000000000000000000..56fbbe61ef9d0d4699af6d1edb1b948f9b02e15a --- /dev/null +++ b/tests/components/switcher_kis/test_climate.py @@ -0,0 +1,341 @@ +"""Test the Switcher climate platform.""" +from unittest.mock import ANY, patch + +from aioswitcher.api import SwitcherBaseResponse +from aioswitcher.device import ( + DeviceState, + ThermostatFanLevel, + ThermostatMode, + ThermostatSwing, +) +import pytest + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import slugify + +from . import init_integration +from .consts import DUMMY_THERMOSTAT_DEVICE as DEVICE + +ENTITY_ID = f"{CLIMATE_DOMAIN}.{slugify(DEVICE.name)}" + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_climate_hvac_mode(hass, mock_bridge, mock_api, monkeypatch): + """Test climate hvac mode service.""" + await init_integration(hass) + assert mock_bridge + + # Test initial hvac mode - cool + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + + # Test set hvac mode heat + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "mode", ThermostatMode.HEAT) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with( + ANY, state=DeviceState.ON, mode=ThermostatMode.HEAT + ) + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.HEAT + + # Test set hvac mode off + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "device_state", DeviceState.OFF) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ANY, state=DeviceState.OFF) + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.OFF + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_climate_temperature(hass, mock_bridge, mock_api, monkeypatch): + """Test climate temperature service.""" + await init_integration(hass) + assert mock_bridge + + # Test initial target temperature + state = hass.states.get(ENTITY_ID) + assert state.attributes["temperature"] == 23 + + # Test set target temperature + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 22}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "target_temperature", 22) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ANY, target_temp=22) + state = hass.states.get(ENTITY_ID) + assert state.attributes["temperature"] == 22 + + # Test set target temperature - incorrect params + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TARGET_TEMP_LOW: 20, + ATTR_TARGET_TEMP_HIGH: 30, + }, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_not_called() + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_climate_fan_level(hass, mock_bridge, mock_api, monkeypatch): + """Test climate fan level service.""" + await init_integration(hass) + assert mock_bridge + + # Test initial fan level - low + state = hass.states.get(ENTITY_ID) + assert state.attributes["fan_mode"] == "low" + + # Test set fan level to high + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "high"}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "fan_level", ThermostatFanLevel.HIGH) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with( + ANY, fan_level=ThermostatFanLevel.HIGH + ) + state = hass.states.get(ENTITY_ID) + assert state.attributes["fan_mode"] == "high" + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_climate_swing(hass, mock_bridge, mock_api, monkeypatch): + """Test climate swing service.""" + await init_integration(hass) + assert mock_bridge + + # Test initial swing mode + state = hass.states.get(ENTITY_ID) + assert state.attributes["swing_mode"] == "off" + + # Test set swing mode on + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_SWING_MODE: "vertical", + }, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "swing", ThermostatSwing.ON) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ANY, swing=ThermostatSwing.ON) + state = hass.states.get(ENTITY_ID) + assert state.attributes["swing_mode"] == "vertical" + + # Test set swing mode off + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: "off"}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "swing", ThermostatSwing.OFF) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ANY, swing=ThermostatSwing.OFF) + state = hass.states.get(ENTITY_ID) + assert state.attributes["swing_mode"] == "off" + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_control_device_fail(hass, mock_bridge, mock_api, monkeypatch): + """Test control device fail.""" + await init_integration(hass) + assert mock_bridge + + # Test initial hvac mode - cool + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + + # Test exception during set hvac mode + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with( + ANY, state=DeviceState.ON, mode=ThermostatMode.HEAT + ) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + + # Test error response during turn on + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with( + ANY, state=DeviceState.ON, mode=ThermostatMode.HEAT + ) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_bad_update_discard(hass, mock_bridge, mock_api, monkeypatch): + """Test that a bad update from device is discarded.""" + await init_integration(hass) + assert mock_bridge + + # Test initial hvac mode - cool + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + + # Device send target temperature with 0 to indicate it doesn't have data + monkeypatch.setattr(DEVICE, "target_temperature", 0) + monkeypatch.setattr(DEVICE, "mode", ThermostatMode.HEAT) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + # Validate state did not change + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.COOL + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_climate_control_errors(hass, mock_bridge, mock_api, monkeypatch): + """Test control with settings not supported by device.""" + await init_integration(hass) + assert mock_bridge + + # Dry mode does not support setting fan, temperature, swing + monkeypatch.setattr(DEVICE, "mode", ThermostatMode.DRY) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + # Test exception when trying set temperature + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 24}, + blocking=True, + ) + + # Test exception when trying set fan level + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "high"}, + blocking=True, + ) + + # Test exception when trying set swing mode + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: "off"}, + blocking=True, + ) diff --git a/tests/components/switcher_kis/test_cover.py b/tests/components/switcher_kis/test_cover.py new file mode 100644 index 0000000000000000000000000000000000000000..a4c8b84dadb90426386c2b5238ee0309991d0210 --- /dev/null +++ b/tests/components/switcher_kis/test_cover.py @@ -0,0 +1,183 @@ +"""Test the Switcher cover platform.""" +from unittest.mock import patch + +from aioswitcher.api import SwitcherBaseResponse +from aioswitcher.device import ShutterDirection +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import slugify + +from . import init_integration +from .consts import DUMMY_SHUTTER_DEVICE as DEVICE + +ENTITY_ID = f"{COVER_DOMAIN}.{slugify(DEVICE.name)}" + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_cover(hass, mock_bridge, mock_api, monkeypatch): + """Test cover services.""" + await init_integration(hass) + assert mock_bridge + + # Test initial state - open + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OPEN + + # Test set position + with patch( + "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position" + ) as mock_control_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 77}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "position", 77) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(77) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 77 + + # Test open + with patch( + "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position" + ) as mock_control_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "direction", ShutterDirection.SHUTTER_UP) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(100) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OPENING + + # Test close + with patch( + "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position" + ) as mock_control_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "direction", ShutterDirection.SHUTTER_DOWN) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 6 + mock_control_device.assert_called_once_with(0) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_CLOSING + + # Test stop + with patch( + "homeassistant.components.switcher_kis.cover.SwitcherType2Api.stop" + ) as mock_control_device: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + monkeypatch.setattr(DEVICE, "direction", ShutterDirection.SHUTTER_STOP) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert mock_api.call_count == 8 + mock_control_device.assert_called_once() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OPEN + + # Test closed on position == 0 + monkeypatch.setattr(DEVICE, "position", 0) + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_cover_control_fail(hass, mock_bridge, mock_api): + """Test cover control fail.""" + await init_integration(hass) + assert mock_bridge + + # Test initial state - open + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OPEN + + # Test exception during set position + with patch( + "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 44}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(44) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OPEN + + # Test error response during set position + with patch( + "homeassistant.components.switcher_kis.cover.SwitcherType2Api.set_position", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 27}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(27) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..8655ba7ee1f98e1711b01b846fdca61f062b430f --- /dev/null +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -0,0 +1,59 @@ +"""Tests for the diagnostics data provided by Switcher.""" +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from . import init_integration +from .consts import DUMMY_WATER_HEATER_DEVICE + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, hass_client: ClientSession, mock_bridge, monkeypatch +) -> None: + """Test diagnostics.""" + entry = await init_integration(hass) + device = DUMMY_WATER_HEATER_DEVICE + monkeypatch.setattr(device, "last_data_update", "2022-09-28T16:42:12.706017") + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == { + "devices": [ + { + "auto_shutdown": "02:00:00", + "device_id": REDACTED, + "device_state": { + "__type": "<enum 'DeviceState'>", + "repr": "<DeviceState.ON: ('01', 'on')>", + }, + "device_type": { + "__type": "<enum 'DeviceType'>", + "repr": "<DeviceType.V4: ('Switcher V4', '0317', " + "1, <DeviceCategory.WATER_HEATER: 1>)>", + }, + "electric_current": 12.8, + "ip_address": REDACTED, + "last_data_update": "2022-09-28T16:42:12.706017", + "mac_address": REDACTED, + "name": "Heater FE12", + "power_consumption": 2780, + "remaining_time": "01:29:32", + } + ], + "entry": { + "entry_id": entry.entry_id, + "version": 1, + "domain": "switcher_kis", + "title": "Mock Title", + "data": {}, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": "switcher_kis", + "disabled_by": None, + }, + } diff --git a/tests/components/switcher_kis/test_services.py b/tests/components/switcher_kis/test_services.py index 9b0fcee27df7b011f4112668584924474ee3e206..cfc51402be0a3da7a199ca037d90b6b9d15c2642 100644 --- a/tests/components/switcher_kis/test_services.py +++ b/tests/components/switcher_kis/test_services.py @@ -44,7 +44,7 @@ async def test_turn_on_with_timer_service(hass, mock_bridge, mock_api, monkeypat assert state.state == STATE_OFF with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device" ) as mock_control_device: await hass.services.async_call( DOMAIN, @@ -74,7 +74,7 @@ async def test_set_auto_off_service(hass, mock_bridge, mock_api): entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown" + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.set_auto_shutdown" ) as mock_set_auto_shutdown: await hass.services.async_call( DOMAIN, @@ -99,7 +99,7 @@ async def test_set_auto_off_service_fail(hass, mock_bridge, mock_api, caplog): entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown", + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.set_auto_shutdown", return_value=None, ) as mock_set_auto_shutdown: await hass.services.async_call( diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py index a44e0c796110e152905f922c7fbf6d7be658d8e0..447de2352fe770ada7b3401db09c75467d0a929c 100644 --- a/tests/components/switcher_kis/test_switch.py +++ b/tests/components/switcher_kis/test_switch.py @@ -43,7 +43,7 @@ async def test_switch(hass, mock_bridge, mock_api, monkeypatch): # Test turning on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device", ) as mock_control_device: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -56,7 +56,7 @@ async def test_switch(hass, mock_bridge, mock_api, monkeypatch): # Test turning off with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device" ) as mock_control_device: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -87,7 +87,7 @@ async def test_switch_control_fail(hass, mock_bridge, mock_api, monkeypatch, cap # Test exception during turn on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device", side_effect=RuntimeError("fake error"), ) as mock_control_device: await hass.services.async_call( @@ -111,7 +111,7 @@ async def test_switch_control_fail(hass, mock_bridge, mock_api, monkeypatch, cap # Test error response during turn on with patch( - "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + "homeassistant.components.switcher_kis.switch.SwitcherType1Api.control_device", return_value=SwitcherBaseResponse(None), ) as mock_control_device: await hass.services.async_call( diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index 96e5480acb5565554387c3afd389ebdb3bb4890d..53cb6531aaf5aa9f4c420afe18b72cef0c6d47b9 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -136,6 +136,28 @@ async def test_warning(hass, hass_ws_client): assert_log(log, "", "warning message", "WARNING") +async def test_warning_good_format(hass, hass_ws_client): + """Test that warning with good format arguments are logged and retrieved correctly.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) + await hass.async_block_till_done() + _LOGGER.warning("warning message: %s", "test") + await hass.async_block_till_done() + + log = find_log(await get_error_log(hass_ws_client), "WARNING") + assert_log(log, "", "warning message: test", "WARNING") + + +async def test_warning_missing_format_args(hass, hass_ws_client): + """Test that warning with missing format arguments are logged and retrieved correctly.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) + await hass.async_block_till_done() + _LOGGER.warning("warning message missing a format arg %s") + await hass.async_block_till_done() + + log = find_log(await get_error_log(hass_ws_client), "WARNING") + assert_log(log, "", ["warning message missing a format arg %s"], "WARNING") + + async def test_error(hass, hass_ws_client): """Test that errors are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 30bb9e00d59e10ac2907792f6efdca0fbbffed18..503def425c286ba416dd5c583ad404e55fe49c5f 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -491,7 +491,7 @@ async def test_set_percentage(hass, calls): for state, value in [ (STATE_ON, 100), (STATE_ON, 66), - (STATE_OFF, 0), + (STATE_ON, 0), ]: await common.async_set_percentage(hass, _TEST_FAN, value) assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value @@ -516,7 +516,7 @@ async def test_increase_decrease_speed(hass, calls): (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 66), (common.async_decrease_speed, None, STATE_ON, 33), - (common.async_decrease_speed, None, STATE_OFF, 0), + (common.async_decrease_speed, None, STATE_ON, 0), (common.async_increase_speed, None, STATE_ON, 33), ]: await func(hass, _TEST_FAN, extra) @@ -524,6 +524,116 @@ async def test_increase_decrease_speed(hass, calls): _verify(hass, state, value, None, None, None) +async def test_no_value_template(hass, calls): + """Test a fan without a value_template.""" + await _register_fan_sources(hass) + + with assert_setup_component(1, "fan"): + test_fan_config = { + "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "percentage_template": "{{ states('input_number.percentage') }}", + "oscillating_template": "{{ states('input_select.osc') }}", + "direction_template": "{{ states('input_select.direction') }}", + "turn_on": [ + { + "service": "input_boolean.turn_on", + "entity_id": _STATE_INPUT_BOOLEAN, + }, + { + "service": "test.automation", + "data_template": { + "action": "turn_on", + "caller": "{{ this.entity_id }}", + }, + }, + ], + "turn_off": [ + { + "service": "input_boolean.turn_off", + "entity_id": _STATE_INPUT_BOOLEAN, + }, + { + "service": "test.automation", + "data_template": { + "action": "turn_off", + "caller": "{{ this.entity_id }}", + }, + }, + ], + "set_preset_mode": [ + { + "service": "input_select.select_option", + "data_template": { + "entity_id": _PRESET_MODE_INPUT_SELECT, + "option": "{{ preset_mode }}", + }, + }, + { + "service": "test.automation", + "data_template": { + "action": "set_preset_mode", + "caller": "{{ this.entity_id }}", + "option": "{{ preset_mode }}", + }, + }, + ], + "set_percentage": [ + { + "service": "input_number.set_value", + "data_template": { + "entity_id": _PERCENTAGE_INPUT_NUMBER, + "value": "{{ percentage }}", + }, + }, + { + "service": "test.automation", + "data_template": { + "action": "set_value", + "caller": "{{ this.entity_id }}", + "value": "{{ percentage }}", + }, + }, + ], + } + assert await setup.async_setup_component( + hass, + "fan", + {"fan": {"platform": "template", "fans": {"test_fan": test_fan_config}}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + await common.async_turn_on(hass, _TEST_FAN) + _verify(hass, STATE_ON, 0, None, None, None) + + await common.async_turn_off(hass, _TEST_FAN) + _verify(hass, STATE_OFF, 0, None, None, None) + + percent = 100 + await common.async_set_percentage(hass, _TEST_FAN, percent) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == percent + _verify(hass, STATE_ON, percent, None, None, None) + + await common.async_turn_off(hass, _TEST_FAN) + _verify(hass, STATE_OFF, percent, None, None, None) + + preset = "auto" + await common.async_set_preset_mode(hass, _TEST_FAN, preset) + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == preset + _verify(hass, STATE_ON, percent, None, None, preset) + + await common.async_turn_off(hass, _TEST_FAN) + _verify(hass, STATE_OFF, percent, None, None, preset) + + await common.async_set_direction(hass, _TEST_FAN, True) + _verify(hass, STATE_OFF, percent, None, None, preset) + + await common.async_oscillate(hass, _TEST_FAN, True) + _verify(hass, STATE_OFF, percent, None, None, preset) + + async def test_increase_decrease_speed_default_speed_count(hass, calls): """Test set valid increase and decrease speed.""" await _register_components(hass) @@ -585,10 +695,7 @@ def _verify( assert attributes.get(ATTR_PRESET_MODE) == expected_preset_mode -async def _register_components( - hass, speed_list=None, preset_modes=None, speed_count=None -): - """Register basic components for testing.""" +async def _register_fan_sources(hass): with assert_setup_component(1, "input_boolean"): assert await setup.async_setup_component( hass, "input_boolean", {"input_boolean": {"state": None}} @@ -630,6 +737,13 @@ async def _register_components( }, ) + +async def _register_components( + hass, speed_list=None, preset_modes=None, speed_count=None +): + """Register basic components for testing.""" + await _register_fan_sources(hass) + with assert_setup_component(1, "fan"): value_template = """ {% if is_state('input_boolean.state', 'on') %} diff --git a/tests/components/tibber/test_config_flow.py b/tests/components/tibber/test_config_flow.py index 1d42c9826e910bf45005db87ab0b6fb23a3480a6..acd1d2a842eddd74660fdc38772253acb3cc24af 100644 --- a/tests/components/tibber/test_config_flow.py +++ b/tests/components/tibber/test_config_flow.py @@ -15,7 +15,7 @@ def tibber_setup_fixture(): yield -async def test_show_config_form(hass, recorder_mock): +async def test_show_config_form(recorder_mock, hass): """Test show configuration form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -25,7 +25,7 @@ async def test_show_config_form(hass, recorder_mock): assert result["step_id"] == "user" -async def test_create_entry(hass, recorder_mock): +async def test_create_entry(recorder_mock, hass): """Test create entry from user input.""" test_data = { CONF_ACCESS_TOKEN: "valid", @@ -49,7 +49,7 @@ async def test_create_entry(hass, recorder_mock): assert result["data"] == test_data -async def test_flow_entry_already_exists(hass, recorder_mock, config_entry): +async def test_flow_entry_already_exists(recorder_mock, hass, config_entry): """Test user input for config_entry that already exists.""" test_data = { CONF_ACCESS_TOKEN: "valid", diff --git a/tests/components/tibber/test_diagnostics.py b/tests/components/tibber/test_diagnostics.py index c92104128203ce57cd3fefd1bc553835b5c2b39c..38b5eb91a2f762b63360a79db7f45210b8516310 100644 --- a/tests/components/tibber/test_diagnostics.py +++ b/tests/components/tibber/test_diagnostics.py @@ -8,7 +8,7 @@ from .test_common import mock_get_homes from tests.components.diagnostics import get_diagnostics_for_config_entry -async def test_entry_diagnostics(hass, hass_client, recorder_mock, config_entry): +async def test_entry_diagnostics(recorder_mock, hass, hass_client, config_entry): """Test config entry diagnostics.""" with patch( "tibber.Tibber.update_info", diff --git a/tests/components/tibber/test_statistics.py b/tests/components/tibber/test_statistics.py index 8e0d24ffa9da669985bc91d889f92b8baac50053..745c434237bb3f3187ddc66a1809590cf6e80f90 100644 --- a/tests/components/tibber/test_statistics.py +++ b/tests/components/tibber/test_statistics.py @@ -10,7 +10,7 @@ from .test_common import CONSUMPTION_DATA_1, PRODUCTION_DATA_1, mock_get_homes from tests.components.recorder.common import async_wait_recording_done -async def test_async_setup_entry(hass, recorder_mock): +async def test_async_setup_entry(recorder_mock, hass): """Test setup Tibber.""" tibber_connection = AsyncMock() tibber_connection.name = "tibber" diff --git a/tests/components/tile/conftest.py b/tests/components/tile/conftest.py index 0cb9a0080f6e238357bc92f377af5224a9b37386..474c784ec3c9a38503bf097150e62256426d438f 100644 --- a/tests/components/tile/conftest.py +++ b/tests/components/tile/conftest.py @@ -26,9 +26,9 @@ def api_fixture(hass, data_tile_details): @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, unique_id): +def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_USERNAME], data=config) entry.add_to_hass(hass) return entry @@ -42,7 +42,7 @@ def config_fixture(hass): } -@pytest.fixture(name="data_tile_details", scope="session") +@pytest.fixture(name="data_tile_details", scope="package") def data_tile_details_fixture(): """Define a Tile details data payload.""" return json.loads(load_fixture("tile_details_data.json", "tile")) @@ -59,9 +59,3 @@ async def setup_tile_fixture(hass, api, config): assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "user@host.com" diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 51b3db00c6e3fdc8bfd5df30826549970cfa61e3..7721e5d36ac1d0e8ddcec60d651f64370914902c 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -25,7 +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 homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import API_V4_ENTRY_DATA @@ -172,7 +172,7 @@ async def test_v4_sensor(hass: HomeAssistant) -> None: async def test_v4_sensor_imperial(hass: HomeAssistant) -> None: """Test v4 sensor data.""" - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await _setup(hass, V4_FIELDS, API_V4_ENTRY_DATA) check_sensor_state(hass, O3, "91.35") check_sensor_state(hass, CO, "0.0") diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index e2b77ac1ed174693796949f288f8e179dbb85eb6..19574a9ab424fa972a41be631d54b67030e93b0c 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -3,7 +3,8 @@ from __future__ import annotations from unittest.mock import patch -from aiounifi.websocket import SIGNAL_CONNECTION_STATE, SIGNAL_DATA +from aiounifi.models.message import MessageKey +from aiounifi.websocket import WebsocketSignal, WebsocketState import pytest from homeassistant.helpers import device_registry as dr @@ -16,14 +17,27 @@ def mock_unifi_websocket(): """No real websocket allowed.""" with patch("aiounifi.controller.WSClient") as mock: - def make_websocket_call(data: dict | None = None, state: str = ""): + def make_websocket_call( + *, + message: MessageKey | None = None, + data: list[dict] | dict | None = None, + state: WebsocketState | None = None, + ): """Generate a websocket call.""" - if data: + if data and not message: mock.return_value.data = data - mock.call_args[1]["callback"](SIGNAL_DATA) + mock.call_args[1]["callback"](WebsocketSignal.DATA) + elif data and message: + if not isinstance(data, list): + data = [data] + mock.return_value.data = { + "meta": {"message": message.value}, + "data": data, + } + mock.call_args[1]["callback"](WebsocketSignal.DATA) elif state: mock.return_value.state = state - mock.call_args[1]["callback"](SIGNAL_CONNECTION_STATE) + mock.call_args[1]["callback"](WebsocketSignal.CONNECTION_STATE) else: raise NotImplementedError diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 5de99a3f4348da3fa28a1bdd4034c17e2f063f69..3861c5b38bdf62ddbb4a020a3b87bd0bbe9947e2 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -7,7 +7,9 @@ from http import HTTPStatus from unittest.mock import Mock, patch import aiounifi -from aiounifi.websocket import STATE_DISCONNECTED, STATE_RUNNING +from aiounifi.models.event import EventKey +from aiounifi.models.message import MessageKey +from aiounifi.websocket import WebsocketState import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -359,13 +361,13 @@ async def test_connection_state_signalling( # Controller is connected assert hass.states.get("device_tracker.client").state == "home" - mock_unifi_websocket(state=STATE_DISCONNECTED) + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() # Controller is disconnected assert hass.states.get("device_tracker.client").state == "unavailable" - mock_unifi_websocket(state=STATE_RUNNING) + mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() # Controller is once again connected @@ -396,21 +398,14 @@ async def test_wireless_client_event_calls_update_wireless_devices( "homeassistant.components.unifi.controller.UniFiController.update_wireless_clients", return_value=None, ) as wireless_clients_mock: - mock_unifi_websocket( - data={ - "meta": {"rc": "ok", "message": "events"}, - "data": [ - { - "datetime": "2020-01-20T19:37:04Z", - "user": "00:00:00:00:00:01", - "key": aiounifi.events.WIRELESS_CLIENT_CONNECTED, - "msg": "User[11:22:33:44:55:66] has connected to WLAN", - "time": 1579549024893, - } - ], - }, - ) - + event = { + "datetime": "2020-01-20T19:37:04Z", + "user": "00:00:00:00:00:01", + "key": EventKey.WIRELESS_CLIENT_CONNECTED.value, + "msg": "User[11:22:33:44:55:66] has connected to WLAN", + "time": 1579549024893, + } + mock_unifi_websocket(message=MessageKey.EVENT, data=event) assert wireless_clients_mock.assert_called_once @@ -423,7 +418,7 @@ async def test_reconnect_mechanism(hass, aioclient_mock, mock_unifi_websocket): f"https://{DEFAULT_HOST}:1234/api/login", status=HTTPStatus.BAD_GATEWAY ) - mock_unifi_websocket(state=STATE_DISCONNECTED) + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() assert aioclient_mock.call_count == 0 @@ -459,7 +454,7 @@ async def test_reconnect_mechanism_exceptions( with patch("aiounifi.Controller.login", side_effect=exception), patch( "homeassistant.components.unifi.controller.UniFiController.reconnect" ) as mock_reconnect: - mock_unifi_websocket(state=STATE_DISCONNECTED) + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index e5d53a6d882aa9f69128e6a1ce07d2336c6e3229..b8f1aa771a4362afc2d31ce26e48bb57acbfa605 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -3,13 +3,8 @@ from datetime import timedelta from unittest.mock import patch -from aiounifi.controller import ( - MESSAGE_CLIENT, - MESSAGE_CLIENT_REMOVED, - MESSAGE_DEVICE, - MESSAGE_EVENT, -) -from aiounifi.websocket import STATE_DISCONNECTED, STATE_RUNNING +from aiounifi.models.message import MessageKey +from aiounifi.websocket import WebsocketState from homeassistant import config_entries from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -62,12 +57,7 @@ async def test_tracked_wireless_clients( # Updated timestamp marks client as home client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -83,12 +73,7 @@ async def test_tracked_wireless_clients( # Same timestamp doesn't explicitly mark client as away - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -163,12 +148,7 @@ async def test_tracked_clients( # State change signalling works client_1["last_seen"] += 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client_1], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client_1) await hass.async_block_till_done() assert hass.states.get("device_tracker.client_1").state == STATE_HOME @@ -213,14 +193,8 @@ async def test_tracked_wireless_clients_event_source( "msg": f'User{[client["mac"]]} has connected to AP[{client["ap_mac"]}] with SSID "{client["essid"]}" on "channel 44(na)"', "_id": "5ea331fa30c49e00f90ddc1a", } - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [event], - } - ) + mock_unifi_websocket(message=MessageKey.EVENT, data=event) await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_HOME # Disconnected event @@ -240,12 +214,7 @@ async def test_tracked_wireless_clients_event_source( "msg": f'User{[client["mac"]]} disconnected from "{client["essid"]}" (7m 47s connected, 448.28K bytes, last AP[{client["ap_mac"]}])', "_id": "5ea32ff730c49e00f90dca1a", } - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [event], - } - ) + mock_unifi_websocket(message=MessageKey.EVENT, data=event) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -263,14 +232,8 @@ async def test_tracked_wireless_clients_event_source( # New data - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_HOME # Disconnection event will be ignored @@ -290,12 +253,7 @@ async def test_tracked_wireless_clients_event_source( "msg": f'User{[client["mac"]]} disconnected from "{client["essid"]}" (7m 47s connected, 448.28K bytes, last AP[{client["ap_mac"]}])', "_id": "5ea32ff730c49e00f90dca1a", } - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [event], - } - ) + mock_unifi_websocket(message=MessageKey.EVENT, data=event) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -355,19 +313,8 @@ async def test_tracked_devices( # State change signalling work device_1["next_interval"] = 20 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [device_1], - } - ) device_2["next_interval"] = 50 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [device_2], - } - ) + mock_unifi_websocket(message=MessageKey.DEVICE, data=[device_1, device_2]) await hass.async_block_till_done() assert hass.states.get("device_tracker.device_1").state == STATE_HOME @@ -386,12 +333,7 @@ async def test_tracked_devices( # Disabled device is unavailable device_1["disabled"] = True - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [device_1], - } - ) + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get("device_tracker.device_1").state == STATE_UNAVAILABLE @@ -425,12 +367,7 @@ async def test_remove_clients( # Remove client - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT_REMOVED}, - "data": [client_1], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=client_1) await hass.async_block_till_done() await hass.async_block_till_done() @@ -480,14 +417,14 @@ async def test_controller_state_change( assert hass.states.get("device_tracker.device").state == STATE_HOME # Controller unavailable - mock_unifi_websocket(state=STATE_DISCONNECTED) + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_UNAVAILABLE assert hass.states.get("device_tracker.device").state == STATE_UNAVAILABLE # Controller available - mock_unifi_websocket(state=STATE_RUNNING) + mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME @@ -728,20 +665,11 @@ async def test_option_ssid_filter( # Roams to SSID outside of filter client["essid"] = "other_ssid" - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) + # Data update while SSID filter is in effect shouldn't create the client client_on_ssid2["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client_on_ssid2], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() # SSID filter marks client as away @@ -759,18 +687,7 @@ async def test_option_ssid_filter( client["last_seen"] += 1 client_on_ssid2["last_seen"] += 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client_on_ssid2], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=[client, client_on_ssid2]) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -786,12 +703,7 @@ async def test_option_ssid_filter( assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME client_on_ssid2["last_seen"] += 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client_on_ssid2], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() # Client won't go away until after next update @@ -799,12 +711,7 @@ async def test_option_ssid_filter( # Trigger update to get client marked as away client_on_ssid2["last_seen"] += 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client_on_ssid2], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() new_time = ( @@ -848,12 +755,7 @@ async def test_wireless_client_go_wired_issue( # Trigger wired bug client["last_seen"] += 1 client["is_wired"] = True - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Wired bug fix keeps client marked as wireless @@ -874,12 +776,7 @@ async def test_wireless_client_go_wired_issue( # Try to mark client as connected client["last_seen"] += 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Make sure it don't go online again until wired bug disappears @@ -890,12 +787,7 @@ async def test_wireless_client_go_wired_issue( # Make client wireless client["last_seen"] += 1 client["is_wired"] = False - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Client is no longer affected by wired bug and can be marked online @@ -934,12 +826,7 @@ async def test_option_ignore_wired_bug( # Trigger wired bug client["is_wired"] = True - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Wired bug in effect @@ -960,12 +847,7 @@ async def test_option_ignore_wired_bug( # Mark client as connected again client["last_seen"] += 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Ignoring wired bug allows client to go home again even while affected @@ -976,12 +858,7 @@ async def test_option_ignore_wired_bug( # Make client wireless client["last_seen"] += 1 client["is_wired"] = False - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Client is wireless and still connected diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 398c6c6c3f57e7af4f979340efdfe639273f6b85..100918a93dae574f68e38c4f8e3b138b77e49831 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -3,7 +3,7 @@ from datetime import datetime from unittest.mock import patch -from aiounifi.controller import MESSAGE_CLIENT, MESSAGE_CLIENT_REMOVED +from aiounifi.models.message import MessageKey import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -87,12 +87,7 @@ async def test_bandwidth_sensors(hass, aioclient_mock, mock_unifi_websocket): wireless_client["rx_bytes-r"] = 3456000000 wireless_client["tx_bytes-r"] = 7891000000 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [wireless_client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client) await hass.async_block_till_done() assert hass.states.get("sensor.wireless_client_rx").state == "3456.0" @@ -199,12 +194,7 @@ async def test_uptime_sensors( uptime_client["uptime"] = event_uptime now = datetime(2021, 1, 1, 1, 1, 4, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [uptime_client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=uptime_client) await hass.async_block_till_done() assert hass.states.get("sensor.client1_uptime").state == "2021-01-01T01:00:00+00:00" @@ -215,12 +205,7 @@ async def test_uptime_sensors( uptime_client["uptime"] = new_uptime now = datetime(2021, 2, 1, 1, 1, 0, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [uptime_client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=uptime_client) await hass.async_block_till_done() assert hass.states.get("sensor.client1_uptime").state == "2021-02-01T01:00:00+00:00" @@ -308,12 +293,7 @@ async def test_remove_sensors(hass, aioclient_mock, mock_unifi_websocket): # Remove wired client - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT_REMOVED}, - "data": [wired_client], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=wired_client) await hass.async_block_till_done() assert len(hass.states.async_all()) == 5 diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 9965c25a1b5a68651bbf2afd6d6fcdbd508b006f..e6357b031725b0be6f00fc5ab9cb09f41c8dae41 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -1,13 +1,17 @@ """UniFi Network switch platform tests.""" + from copy import deepcopy +from datetime import timedelta -from aiounifi.controller import MESSAGE_CLIENT_REMOVED, MESSAGE_DEVICE, MESSAGE_EVENT +from aiounifi.models.message import MessageKey +from aiounifi.websocket import WebsocketState from homeassistant import config_entries, core from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SwitchDeviceClass, ) from homeassistant.components.unifi.const import ( CONF_BLOCK_CLIENT, @@ -18,10 +22,19 @@ from homeassistant.components.unifi.const import ( DOMAIN as UNIFI_DOMAIN, ) from homeassistant.components.unifi.switch import POE_SWITCH -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_registry import RegistryEntryDisabler +from homeassistant.util import dt from .test_controller import ( CONTROLLER_HOST, @@ -31,7 +44,7 @@ from .test_controller import ( setup_unifi_integration, ) -from tests.common import mock_restore_cache +from tests.common import async_fire_time_changed, mock_restore_cache CLIENT_1 = { "hostname": "client_1", @@ -41,7 +54,7 @@ CLIENT_1 = { "mac": "00:00:00:00:00:01", "name": "POE Client 1", "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", + "sw_mac": "10:00:00:00:01:01", "sw_port": 1, "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, @@ -54,7 +67,7 @@ CLIENT_2 = { "mac": "00:00:00:00:00:02", "name": "POE Client 2", "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", + "sw_mac": "10:00:00:00:01:01", "sw_port": 2, "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, @@ -67,7 +80,7 @@ CLIENT_3 = { "mac": "00:00:00:00:00:03", "name": "Non-POE Client 3", "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", + "sw_mac": "10:00:00:00:01:01", "sw_port": 3, "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, @@ -80,7 +93,7 @@ CLIENT_4 = { "mac": "00:00:00:00:00:04", "name": "Non-POE Client 4", "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", + "sw_mac": "10:00:00:00:01:01", "sw_port": 4, "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, @@ -94,7 +107,7 @@ POE_SWITCH_CLIENTS = [ "mac": "00:00:00:00:00:01", "name": "POE Client 1", "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", + "sw_mac": "10:00:00:00:01:01", "sw_port": 1, "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, @@ -107,7 +120,7 @@ POE_SWITCH_CLIENTS = [ "mac": "00:00:00:00:00:02", "name": "POE Client 2", "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", + "sw_mac": "10:00:00:00:01:01", "sw_port": 1, "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, @@ -115,9 +128,10 @@ POE_SWITCH_CLIENTS = [ ] DEVICE_1 = { + "board_rev": 2, "device_id": "mock-id", "ip": "10.0.1.1", - "mac": "00:00:00:00:01:01", + "mac": "10:00:00:00:01:01", "last_seen": 1562600145, "model": "US16P150", "name": "mock-name", @@ -400,9 +414,16 @@ OUTLET_UP1 = { "index": 1, "has_relay": True, "has_metering": False, + "relay_state": True, + "name": "Outlet 1", + }, + { + "index": 2, + "has_relay": False, + "has_metering": False, "relay_state": False, "name": "Outlet 1", - } + }, ], "element_ap_serial": "44:d9:e7:90:f4:24", "connected_at": 1641678609, @@ -629,7 +650,7 @@ async def test_switches(hass, aioclient_mock): assert switch_1 is not None assert switch_1.state == "on" assert switch_1.attributes["power"] == "2.56" - assert switch_1.attributes[SWITCH_DOMAIN] == "00:00:00:00:01:01" + assert switch_1.attributes[SWITCH_DOMAIN] == "10:00:00:00:01:01" assert switch_1.attributes["port"] == 1 assert switch_1.attributes["poe_mode"] == "auto" @@ -729,12 +750,7 @@ async def test_remove_switches(hass, aioclient_mock, mock_unifi_websocket): assert hass.states.get("switch.block_client_2") is not None assert hass.states.get("switch.block_media_streaming") is not None - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT_REMOVED}, - "data": [CLIENT_1, UNBLOCKED], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=[CLIENT_1, UNBLOCKED]) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 @@ -745,7 +761,6 @@ async def test_remove_switches(hass, aioclient_mock, mock_unifi_websocket): mock_unifi_websocket(data=DPI_GROUP_REMOVED_EVENT) await hass.async_block_till_done() - await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming") is None assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 @@ -776,12 +791,7 @@ async def test_block_switches(hass, aioclient_mock, mock_unifi_websocket): assert unblocked is not None assert unblocked.state == "on" - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [EVENT_BLOCKED_CLIENT_UNBLOCKED], - } - ) + mock_unifi_websocket(message=MessageKey.EVENT, data=EVENT_BLOCKED_CLIENT_UNBLOCKED) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 @@ -789,12 +799,7 @@ async def test_block_switches(hass, aioclient_mock, mock_unifi_websocket): assert blocked is not None assert blocked.state == "on" - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [EVENT_BLOCKED_CLIENT_BLOCKED], - } - ) + mock_unifi_websocket(message=MessageKey.EVENT, data=EVENT_BLOCKED_CLIENT_BLOCKED) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 @@ -846,9 +851,20 @@ async def test_dpi_switches(hass, aioclient_mock, mock_unifi_websocket): assert hass.states.get("switch.block_media_streaming").state == STATE_OFF - mock_unifi_websocket(data=DPI_GROUP_REMOVE_APP) + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() + assert hass.states.get("switch.block_media_streaming").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() + assert hass.states.get("switch.block_media_streaming").state == STATE_OFF + + # Remove app + mock_unifi_websocket(data=DPI_GROUP_REMOVE_APP) await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming") is None @@ -868,95 +884,71 @@ async def test_dpi_switches_add_second_app(hass, aioclient_mock, mock_unifi_webs assert hass.states.get("switch.block_media_streaming").state == STATE_ON second_app_event = { - "meta": {"rc": "ok", "message": "dpiapp:add"}, - "data": [ - { - "apps": [524292], - "blocked": False, - "cats": [], - "enabled": False, - "log": False, - "site_id": "name", - "_id": "61783e89c1773a18c0c61f00", - } - ], + "apps": [524292], + "blocked": False, + "cats": [], + "enabled": False, + "log": False, + "site_id": "name", + "_id": "61783e89c1773a18c0c61f00", } - mock_unifi_websocket(data=second_app_event) + mock_unifi_websocket(message=MessageKey.DPI_APP_ADDED, data=second_app_event) await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming").state == STATE_ON add_second_app_to_group = { - "meta": {"rc": "ok", "message": "dpigroup:sync"}, - "data": [ - { - "_id": "5f976f4ae3c58f018ec7dff6", - "name": "Block Media Streaming", - "site_id": "name", - "dpiapp_ids": ["5f976f62e3c58f018ec7e17d", "61783e89c1773a18c0c61f00"], - } - ], + "_id": "5f976f4ae3c58f018ec7dff6", + "name": "Block Media Streaming", + "site_id": "name", + "dpiapp_ids": ["5f976f62e3c58f018ec7e17d", "61783e89c1773a18c0c61f00"], } - - mock_unifi_websocket(data=add_second_app_to_group) + mock_unifi_websocket( + message=MessageKey.DPI_GROUP_UPDATED, data=add_second_app_to_group + ) await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming").state == STATE_OFF second_app_event_enabled = { - "meta": {"rc": "ok", "message": "dpiapp:sync"}, - "data": [ - { - "apps": [524292], - "blocked": False, - "cats": [], - "enabled": True, - "log": False, - "site_id": "name", - "_id": "61783e89c1773a18c0c61f00", - } - ], + "apps": [524292], + "blocked": False, + "cats": [], + "enabled": True, + "log": False, + "site_id": "name", + "_id": "61783e89c1773a18c0c61f00", } - mock_unifi_websocket(data=second_app_event_enabled) + mock_unifi_websocket( + message=MessageKey.DPI_APP_UPDATED, data=second_app_event_enabled + ) await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming").state == STATE_ON async def test_outlet_switches(hass, aioclient_mock, mock_unifi_websocket): - """Test the update_items function with some clients.""" + """Test the outlet entities.""" config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options={CONF_TRACK_DEVICES: False}, - devices_response=[OUTLET_UP1], + hass, aioclient_mock, devices_response=[OUTLET_UP1] ) controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - outlet = hass.states.get("switch.plug_outlet_1") - assert outlet is not None - assert outlet.state == STATE_OFF - - # State change - - outlet_up1 = deepcopy(OUTLET_UP1) - outlet_up1["outlet_table"][0]["relay_state"] = True + # Validate state object + switch_1 = hass.states.get("switch.plug_outlet_1") + assert switch_1 is not None + assert switch_1.state == STATE_ON + assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [outlet_up1], - } - ) + # Update state object + device_1 = deepcopy(OUTLET_UP1) + device_1["outlet_table"][0]["relay_state"] = False + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() + assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF - outlet = hass.states.get("switch.plug_outlet_1") - assert outlet.state == STATE_ON - - # Turn on and off outlet - + # Turn off outlet aioclient_mock.clear_requests() aioclient_mock.put( f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/600c8356942a6ade50707b56", @@ -964,33 +956,50 @@ async def test_outlet_switches(hass, aioclient_mock, mock_unifi_websocket): await hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_ON, + SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.plug_outlet_1"}, blocking=True, ) assert aioclient_mock.call_count == 1 assert aioclient_mock.mock_calls[0][2] == { - "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": True}] + "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": False}] } + # Turn on outlet await hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_OFF, + SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.plug_outlet_1"}, blocking=True, ) assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[1][2] == { - "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": False}] + "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": True}] } - # Changes to config entry options shouldn't affect outlets - hass.config_entries.async_update_entry( - config_entry, - options={CONF_BLOCK_CLIENT: []}, - ) + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) + await hass.async_block_till_done() + assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF + + # Device gets disabled + device_1["disabled"] = True + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + + # Device gets re-enabled + device_1["disabled"] = False + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF # Unload config entry await hass.config_entries.async_unload(config_entry.entry_id) @@ -1018,31 +1027,13 @@ async def test_new_client_discovered_on_block_control( ) assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + assert hass.states.get("switch.block_client_1") is None - blocked = hass.states.get("switch.block_client_1") - assert blocked is None - - mock_unifi_websocket( - data={ - "meta": {"message": "sta:sync"}, - "data": [BLOCKED], - } - ) - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 - - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [EVENT_BLOCKED_CLIENT_CONNECTED], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=BLOCKED) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - blocked = hass.states.get("switch.block_client_1") - assert blocked is not None + assert hass.states.get("switch.block_client_1") is not None async def test_option_block_clients(hass, aioclient_mock): @@ -1128,22 +1119,12 @@ async def test_new_client_discovered_on_poe_control( assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - mock_unifi_websocket( - data={ - "meta": {"message": "sta:sync"}, - "data": [CLIENT_2], - } - ) + mock_unifi_websocket(message=MessageKey.CLIENT, data=CLIENT_2) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [EVENT_CLIENT_2_CONNECTED], - } - ) + mock_unifi_websocket(message=MessageKey.EVENT, data=EVENT_CLIENT_2_CONNECTED) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 @@ -1373,3 +1354,96 @@ async def test_restore_client_no_old_state(hass, aioclient_mock): poe_client = hass.states.get("switch.poe_client") assert poe_client.state == "unavailable" # self.poe_mode is None + + +async def test_poe_port_switches(hass, aioclient_mock, mock_unifi_websocket): + """Test the update_items function with some clients.""" + config_entry = await setup_unifi_integration( + hass, aioclient_mock, devices_response=[DEVICE_1] + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + + ent_reg = er.async_get(hass) + ent_reg_entry = ent_reg.async_get("switch.mock_name_port_1_poe") + assert ent_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + assert ent_reg_entry.entity_category is EntityCategory.CONFIG + + # Enable entity + ent_reg.async_update_entity( + entity_id="switch.mock_name_port_1_poe", disabled_by=None + ) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Validate state object + switch_1 = hass.states.get("switch.mock_name_port_1_poe") + assert switch_1 is not None + assert switch_1.state == STATE_ON + assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET + + # Update state object + device_1 = deepcopy(DEVICE_1) + device_1["port_table"][0]["poe_mode"] = "off" + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF + + # Turn off PoE + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/mock-id", + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.mock_name_port_1_poe"}, + blocking=True, + ) + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { + "port_overrides": [{"poe_mode": "off", "port_idx": 1, "portconf_id": "1a1"}] + } + + # Turn on PoE + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.mock_name_port_1_poe"}, + blocking=True, + ) + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == { + "port_overrides": [{"poe_mode": "auto", "port_idx": 1, "portconf_id": "1a1"}] + } + + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF + + # Device gets disabled + device_1["disabled"] = True + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_UNAVAILABLE + + # Device gets re-enabled + device_1["disabled"] = False + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index 677491319a1f1b2ab4012bfc0eacb89449a183ad..3c30da0b62dd0cccb3736263bd6026d47437cb65 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -1,8 +1,8 @@ """The tests for the UniFi Network update platform.""" from copy import deepcopy -from aiounifi.controller import MESSAGE_DEVICE -from aiounifi.websocket import STATE_DISCONNECTED, STATE_RUNNING +from aiounifi.models.message import MessageKey +from aiounifi.websocket import WebsocketState from yarl import URL from homeassistant.components.unifi.const import CONF_SITE_ID @@ -64,9 +64,7 @@ async def test_no_entities(hass, aioclient_mock): assert len(hass.states.async_entity_ids(UPDATE_DOMAIN)) == 0 -async def test_device_updates( - hass, aioclient_mock, mock_unifi_websocket, mock_device_registry -): +async def test_device_updates(hass, aioclient_mock, mock_unifi_websocket): """Test the update_items function with some devices.""" device_1 = deepcopy(DEVICE_1) await setup_unifi_integration( @@ -102,12 +100,7 @@ async def test_device_updates( # Simulate start of update device_1["state"] = 4 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [device_1], - } - ) + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() device_1_state = hass.states.get("update.device_1") @@ -122,12 +115,7 @@ async def test_device_updates( device_1["version"] = "4.3.17.11279" device_1["upgradable"] = False del device_1["upgrade_to_firmware"] - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [device_1], - } - ) + mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() device_1_state = hass.states.get("update.device_1") @@ -188,9 +176,7 @@ async def test_install(hass, aioclient_mock): ) -async def test_controller_state_change( - hass, aioclient_mock, mock_unifi_websocket, mock_device_registry -): +async def test_controller_state_change(hass, aioclient_mock, mock_unifi_websocket): """Verify entities state reflect on controller becoming unavailable.""" await setup_unifi_integration( hass, @@ -202,13 +188,13 @@ async def test_controller_state_change( assert hass.states.get("update.device_1").state == STATE_ON # Controller unavailable - mock_unifi_websocket(state=STATE_DISCONNECTED) + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() assert hass.states.get("update.device_1").state == STATE_UNAVAILABLE # Controller available - mock_unifi_websocket(state=STATE_RUNNING) + mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() assert hass.states.get("update.device_1").state == STATE_ON diff --git a/tests/components/update/test_recorder.py b/tests/components/update/test_recorder.py index d1263a720af39bb1472145f7cc18a1ac0babb9af..46588ecf45d7ed4d4ed30dd0a897256afdf2befc 100644 --- a/tests/components/update/test_recorder.py +++ b/tests/components/update/test_recorder.py @@ -21,7 +21,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes( - hass: HomeAssistant, recorder_mock, enable_custom_integrations: None + recorder_mock, hass: HomeAssistant, enable_custom_integrations: None ): """Test update attributes to be excluded.""" platform = getattr(hass.components, f"test.{DOMAIN}") diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index b159a371d9ae5acce81665aff30f290935bdb547..f26fb39e42a11f8622318906bd155524e000f8fd 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -1,11 +1,12 @@ """Configuration for SSDP tests.""" from __future__ import annotations +from datetime import datetime from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch from urllib.parse import urlparse from async_upnp_client.client import UpnpDevice -from async_upnp_client.profiles.igd import IgdDevice, StatusInfo +from async_upnp_client.profiles.igd import IgdDevice, IgdState, StatusInfo import pytest from homeassistant.components import ssdp @@ -65,16 +66,23 @@ def mock_igd_device() -> IgdDevice: mock_igd_device.udn = TEST_DISCOVERY.ssdp_udn mock_igd_device.device = mock_upnp_device - mock_igd_device.async_get_total_bytes_received.return_value = 0 - mock_igd_device.async_get_total_bytes_sent.return_value = 0 - mock_igd_device.async_get_total_packets_received.return_value = 0 - mock_igd_device.async_get_total_packets_sent.return_value = 0 - mock_igd_device.async_get_status_info.return_value = StatusInfo( - "Connected", - "", - 10, + mock_igd_device.async_get_traffic_and_status_data.return_value = IgdState( + timestamp=datetime.now(), + bytes_received=0, + bytes_sent=0, + packets_received=0, + packets_sent=0, + status_info=StatusInfo( + "Connected", + "", + 10, + ), + external_ip_address="8.9.10.11", + kibibytes_per_sec_received=None, + kibibytes_per_sec_sent=None, + packets_per_sec_received=None, + packets_per_sec_sent=None, ) - mock_igd_device.async_get_external_ip_address.return_value = "8.9.10.11" with patch( "homeassistant.components.upnp.device.UpnpFactory.async_create_device" @@ -122,6 +130,10 @@ async def silent_ssdp_scanner(hass): "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" + ), patch( + "homeassistant.components.ssdp.Server._async_start_upnp_servers" + ), patch( + "homeassistant.components.ssdp.Server._async_stop_upnp_servers" ): yield diff --git a/tests/components/upnp/test_binary_sensor.py b/tests/components/upnp/test_binary_sensor.py index 24e5cdce47cb6d253e4b25fa4d5fd49fe7e9cfe5..769a5d790c8678c17a0ac6946b1bd4777d9c807a 100644 --- a/tests/components/upnp/test_binary_sensor.py +++ b/tests/components/upnp/test_binary_sensor.py @@ -1,8 +1,8 @@ """Tests for UPnP/IGD binary_sensor.""" -from datetime import timedelta +from datetime import datetime, timedelta -from async_upnp_client.profiles.igd import StatusInfo +from async_upnp_client.profiles.igd import IgdDevice, IgdState, StatusInfo from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant @@ -20,11 +20,23 @@ async def test_upnp_binary_sensors( assert wan_status_state.state == "on" # Second poll. - mock_igd_device = mock_config_entry.igd_device - mock_igd_device.async_get_status_info.return_value = StatusInfo( - "Disconnected", - "", - 40, + mock_igd_device: IgdDevice = mock_config_entry.igd_device + mock_igd_device.async_get_traffic_and_status_data.return_value = IgdState( + timestamp=datetime.now(), + bytes_received=0, + bytes_sent=0, + packets_received=0, + packets_sent=0, + status_info=StatusInfo( + "Disconnected", + "", + 40, + ), + external_ip_address="8.9.10.11", + kibibytes_per_sec_received=None, + kibibytes_per_sec_sent=None, + packets_per_sec_received=None, + packets_per_sec_sent=None, ) async_fire_time_changed( diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index 2abd357ac31b3f7bfe3b6acd947fd5ef29bfe0f8..f5eb69bfae9c4f17788daf758a7a7603c27fd1bc 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -1,10 +1,8 @@ """Tests for UPnP/IGD sensor.""" -from datetime import timedelta -from unittest.mock import patch +from datetime import datetime, timedelta -from async_upnp_client.profiles.igd import StatusInfo -import pytest +from async_upnp_client.profiles.igd import IgdDevice, IgdState, StatusInfo from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant @@ -14,7 +12,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_upnp_sensors(hass: HomeAssistant, mock_config_entry: MockConfigEntry): - """Test normal sensors.""" + """Test sensors.""" # First poll. assert hass.states.get("sensor.mock_name_b_received").state == "0" assert hass.states.get("sensor.mock_name_b_sent").state == "0" @@ -22,19 +20,30 @@ async def test_upnp_sensors(hass: HomeAssistant, mock_config_entry: MockConfigEn assert hass.states.get("sensor.mock_name_packets_sent").state == "0" assert hass.states.get("sensor.mock_name_external_ip").state == "8.9.10.11" assert hass.states.get("sensor.mock_name_wan_status").state == "Connected" + assert hass.states.get("sensor.mock_name_kib_s_received").state == "unknown" + assert hass.states.get("sensor.mock_name_kib_s_sent").state == "unknown" + assert hass.states.get("sensor.mock_name_packets_s_received").state == "unknown" + assert hass.states.get("sensor.mock_name_packets_s_sent").state == "unknown" # Second poll. - mock_igd_device = mock_config_entry.igd_device - mock_igd_device.async_get_total_bytes_received.return_value = 10240 - mock_igd_device.async_get_total_bytes_sent.return_value = 20480 - mock_igd_device.async_get_total_packets_received.return_value = 30 - mock_igd_device.async_get_total_packets_sent.return_value = 40 - mock_igd_device.async_get_status_info.return_value = StatusInfo( - "Disconnected", - "", - 40, + mock_igd_device: IgdDevice = mock_config_entry.igd_device + mock_igd_device.async_get_traffic_and_status_data.return_value = IgdState( + timestamp=datetime.now(), + bytes_received=10240, + bytes_sent=20480, + packets_received=30, + packets_sent=40, + status_info=StatusInfo( + "Disconnected", + "", + 40, + ), + external_ip_address="", + kibibytes_per_sec_received=10.0, + kibibytes_per_sec_sent=20.0, + packets_per_sec_received=30.0, + packets_per_sec_sent=40.0, ) - mock_igd_device.async_get_external_ip_address.return_value = "" now = dt_util.utcnow() async_fire_time_changed(hass, now + timedelta(seconds=DEFAULT_SCAN_INTERVAL)) @@ -46,50 +55,7 @@ async def test_upnp_sensors(hass: HomeAssistant, mock_config_entry: MockConfigEn assert hass.states.get("sensor.mock_name_packets_sent").state == "40" assert hass.states.get("sensor.mock_name_external_ip").state == "" assert hass.states.get("sensor.mock_name_wan_status").state == "Disconnected" - - -async def test_derived_upnp_sensors( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -): - """Test derived sensors.""" - # First poll. - assert hass.states.get("sensor.mock_name_kib_s_received").state == "unknown" - assert hass.states.get("sensor.mock_name_kib_s_sent").state == "unknown" - assert hass.states.get("sensor.mock_name_packets_s_received").state == "unknown" - assert hass.states.get("sensor.mock_name_packets_s_sent").state == "unknown" - - # Second poll. - mock_igd_device = mock_config_entry.igd_device - mock_igd_device.async_get_total_bytes_received.return_value = int( - 10240 * DEFAULT_SCAN_INTERVAL - ) - mock_igd_device.async_get_total_bytes_sent.return_value = int( - 20480 * DEFAULT_SCAN_INTERVAL - ) - mock_igd_device.async_get_total_packets_received.return_value = int( - 30 * DEFAULT_SCAN_INTERVAL - ) - mock_igd_device.async_get_total_packets_sent.return_value = int( - 40 * DEFAULT_SCAN_INTERVAL - ) - - now = dt_util.utcnow() - with patch( - "homeassistant.components.upnp.device.utcnow", - return_value=now + timedelta(seconds=DEFAULT_SCAN_INTERVAL), - ): - async_fire_time_changed(hass, now + timedelta(seconds=DEFAULT_SCAN_INTERVAL)) - await hass.async_block_till_done() - - assert float( - hass.states.get("sensor.mock_name_kib_s_received").state - ) == pytest.approx(10.0, rel=0.1) - assert float( - hass.states.get("sensor.mock_name_kib_s_sent").state - ) == pytest.approx(20.0, rel=0.1) - assert float( - hass.states.get("sensor.mock_name_packets_s_received").state - ) == pytest.approx(30.0, rel=0.1) - assert float( - hass.states.get("sensor.mock_name_packets_s_sent").state - ) == pytest.approx(40.0, rel=0.1) + assert hass.states.get("sensor.mock_name_kib_s_received").state == "10.0" + assert hass.states.get("sensor.mock_name_kib_s_sent").state == "20.0" + assert hass.states.get("sensor.mock_name_packets_s_received").state == "30.0" + assert hass.states.get("sensor.mock_name_packets_s_sent").state == "40.0" diff --git a/tests/components/vacuum/test_recorder.py b/tests/components/vacuum/test_recorder.py index 040cc9105aaab2376246fc5162f5adb7563f599e..1ca796ba3643fbf150ef0a77e4536c3407316925 100644 --- a/tests/components/vacuum/test_recorder.py +++ b/tests/components/vacuum/test_recorder.py @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test vacuum registered attributes to be excluded.""" await async_setup_component( hass, vacuum.DOMAIN, {vacuum.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/vallox/test_binary_sensor.py b/tests/components/vallox/test_binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..a1bd02cf950ab65fa8576c7d60a53836fd659f4c --- /dev/null +++ b/tests/components/vallox/test_binary_sensor.py @@ -0,0 +1,34 @@ +"""Tests for Vallox binary sensor platform.""" +from typing import Any + +import pytest + +from homeassistant.core import HomeAssistant + +from .conftest import patch_metrics + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "metrics,expected_state", + [ + ({"A_CYC_IO_HEATER": 1}, "on"), + ({"A_CYC_IO_HEATER": 0}, "off"), + ], +) +async def test_binary_sensor_entitity( + metrics: dict[str, Any], + expected_state: str, + mock_entry: MockConfigEntry, + hass: HomeAssistant, +): + """Test binary sensor with metrics.""" + # Act + with patch_metrics(metrics=metrics): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + # Assert + sensor = hass.states.get("binary_sensor.vallox_post_heater") + assert sensor.state == expected_state diff --git a/tests/components/vlc_telnet/test_config_flow.py b/tests/components/vlc_telnet/test_config_flow.py index 5e712c71b24eaaef638ff3fc21ac4afeb0aa743e..f5059517e3e36a6e7de3e6e61211ecf939b88d73 100644 --- a/tests/components/vlc_telnet/test_config_flow.py +++ b/tests/components/vlc_telnet/test_config_flow.py @@ -246,8 +246,10 @@ async def test_hassio_flow(hass: HomeAssistant) -> None: "host": "1.1.1.1", "port": 8888, "name": "custom name", - "addon": "vlc", - } + "addon": "VLC", + }, + name="VLC", + slug="vlc", ) result = await hass.config_entries.flow.async_init( @@ -284,7 +286,7 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=entry_data), + data=HassioServiceInfo(config=entry_data, name="VLC", slug="vlc"), ) await hass.async_block_till_done() @@ -324,8 +326,10 @@ async def test_hassio_errors( "host": "1.1.1.1", "port": 8888, "name": "custom name", - "addon": "vlc", - } + "addon": "VLC", + }, + name="VLC", + slug="vlc", ), ) await hass.async_block_till_done() diff --git a/tests/components/water_heater/test_recorder.py b/tests/components/water_heater/test_recorder.py index b6670152e3f5f0b91e09efaceaa3b63d9c3e964c..549cafc74458c87b3b4f22da69e8f50c2da4e13b 100644 --- a/tests/components/water_heater/test_recorder.py +++ b/tests/components/water_heater/test_recorder.py @@ -20,7 +20,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass, recorder_mock): +async def test_exclude_attributes(recorder_mock, hass): """Test water_heater registered attributes to be excluded.""" await async_setup_component( hass, water_heater.DOMAIN, {water_heater.DOMAIN: {"platform": "demo"}} diff --git a/tests/components/watttime/conftest.py b/tests/components/watttime/conftest.py index 6483778c153aa519ccf6a322d2efd935aec72ea8..f3c1986fcb021a7d9f5904025b2cfe6e0480463e 100644 --- a/tests/components/watttime/conftest.py +++ b/tests/components/watttime/conftest.py @@ -62,11 +62,13 @@ def config_location_type_fixture(hass): @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config_auth, config_coordinates, unique_id): +def config_entry_fixture(hass, config_auth, config_coordinates): """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id=unique_id, + unique_id=( + f"{config_coordinates[CONF_LATITUDE]}, {config_coordinates[CONF_LONGITUDE]}" + ), data={ **config_auth, **config_coordinates, @@ -78,13 +80,13 @@ def config_entry_fixture(hass, config_auth, config_coordinates, unique_id): return entry -@pytest.fixture(name="data_grid_region", scope="session") +@pytest.fixture(name="data_grid_region", scope="package") def data_grid_region_fixture(): """Define grid region data.""" return json.loads(load_fixture("grid_region_data.json", "watttime")) -@pytest.fixture(name="data_realtime_emissions", scope="session") +@pytest.fixture(name="data_realtime_emissions", scope="package") def data_realtime_emissions_fixture(): """Define realtime emissions data.""" return json.loads(load_fixture("realtime_emissions_data.json", "watttime")) @@ -112,9 +114,3 @@ async def setup_watttime_fixture(hass, client, config_auth, config_coordinates): ) await hass.async_block_till_done() yield - - -@pytest.fixture(name="unique_id") -def unique_id_fixture(hass): - """Define a config entry unique ID fixture.""" - return "32.87336, -117.22743" diff --git a/tests/components/watttime/test_diagnostics.py b/tests/components/watttime/test_diagnostics.py index 0d8d87203bbbf9919c166323e463f02cfde75a60..e5aaf65e9208c07791e3b4cb9587ed2f887ddea4 100644 --- a/tests/components/watttime/test_diagnostics.py +++ b/tests/components/watttime/test_diagnostics.py @@ -8,15 +8,24 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_watttime """Test config entry diagnostics.""" assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { + "entry_id": config_entry.entry_id, + "version": 1, + "domain": "watttime", + "title": REDACTED, "data": { "username": REDACTED, "password": REDACTED, "latitude": REDACTED, "longitude": REDACTED, - "balancing_authority": "PJM New Jersey", - "balancing_authority_abbreviation": "PJM_NJ", + "balancing_authority": REDACTED, + "balancing_authority_abbreviation": REDACTED, }, "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, }, "data": { "freq": "300", diff --git a/tests/components/waze_travel_time/test_config_flow.py b/tests/components/waze_travel_time/test_config_flow.py index c4b8144b74d768c3e202ac959739f4e2d5f3550f..d58f8d9a34d42fdf17c4421bfb79fd6fa66f68b7 100644 --- a/tests/components/waze_travel_time/test_config_flow.py +++ b/tests/components/waze_travel_time/test_config_flow.py @@ -14,9 +14,12 @@ from homeassistant.components.waze_travel_time.const import ( CONF_UNITS, CONF_VEHICLE_TYPE, DEFAULT_NAME, + DEFAULT_OPTIONS, DOMAIN, + IMPERIAL_UNITS, ) -from homeassistant.const import CONF_NAME, CONF_REGION, CONF_UNIT_SYSTEM_IMPERIAL +from homeassistant.const import CONF_NAME, CONF_REGION +from homeassistant.core import HomeAssistant from .const import MOCK_CONFIG @@ -24,7 +27,7 @@ from tests.common import MockConfigEntry @pytest.mark.usefixtures("validate_config_entry") -async def test_minimum_fields(hass): +async def test_minimum_fields(hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -48,11 +51,12 @@ async def test_minimum_fields(hass): } -async def test_options(hass): +async def test_options(hass: HomeAssistant) -> None: """Test options flow.""" entry = MockConfigEntry( domain=DOMAIN, data=MOCK_CONFIG, + options=DEFAULT_OPTIONS, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -72,7 +76,7 @@ async def test_options(hass): CONF_EXCL_FILTER: "exclude", CONF_INCL_FILTER: "include", CONF_REALTIME: False, - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", }, ) @@ -85,7 +89,7 @@ async def test_options(hass): CONF_EXCL_FILTER: "exclude", CONF_INCL_FILTER: "include", CONF_REALTIME: False, - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", } @@ -96,54 +100,13 @@ async def test_options(hass): CONF_EXCL_FILTER: "exclude", CONF_INCL_FILTER: "include", CONF_REALTIME: False, - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", } @pytest.mark.usefixtures("validate_config_entry") -async def test_import(hass): - """Test import for config flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_REGION: "US", - CONF_AVOID_FERRIES: True, - CONF_AVOID_SUBSCRIPTION_ROADS: True, - CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "exclude", - CONF_INCL_FILTER: "include", - CONF_REALTIME: False, - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_VEHICLE_TYPE: "taxi", - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - entry = hass.config_entries.async_entries(DOMAIN)[0] - assert entry.data == { - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_REGION: "US", - } - assert entry.options == { - CONF_AVOID_FERRIES: True, - CONF_AVOID_SUBSCRIPTION_ROADS: True, - CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "exclude", - CONF_INCL_FILTER: "include", - CONF_REALTIME: False, - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_VEHICLE_TYPE: "taxi", - } - - -@pytest.mark.usefixtures("validate_config_entry") -async def test_dupe(hass): +async def test_dupe(hass: HomeAssistant) -> None: """Test setting up the same entry data twice is OK.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -176,7 +139,9 @@ async def test_dupe(hass): @pytest.mark.usefixtures("invalidate_config_entry") -async def test_invalid_config_entry(hass): +async def test_invalid_config_entry( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -190,3 +155,48 @@ async def test_invalid_config_entry(hass): assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + + assert "Error trying to validate entry" in caplog.text + + +@pytest.mark.usefixtures("mock_update") +async def test_reset_filters(hass: HomeAssistant) -> None: + """Test resetting inclusive and exclusive filters to empty string.""" + options = {**DEFAULT_OPTIONS} + options[CONF_INCL_FILTER] = "test" + options[CONF_EXCL_FILTER] = "test" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=options, entry_id="test" + ) + config_entry.add_to_hass(hass) + 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, data=None + ) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_AVOID_FERRIES: True, + CONF_AVOID_SUBSCRIPTION_ROADS: True, + CONF_AVOID_TOLL_ROADS: True, + CONF_EXCL_FILTER: "", + CONF_INCL_FILTER: "", + CONF_REALTIME: False, + CONF_UNITS: IMPERIAL_UNITS, + CONF_VEHICLE_TYPE: "taxi", + }, + ) + + assert config_entry.options == { + CONF_AVOID_FERRIES: True, + CONF_AVOID_SUBSCRIPTION_ROADS: True, + CONF_AVOID_TOLL_ROADS: True, + CONF_EXCL_FILTER: "", + CONF_INCL_FILTER: "", + CONF_REALTIME: False, + CONF_UNITS: IMPERIAL_UNITS, + CONF_VEHICLE_TYPE: "taxi", + } diff --git a/tests/components/waze_travel_time/test_init.py b/tests/components/waze_travel_time/test_init.py deleted file mode 100644 index bf8f6a95844049f0b8d4f8270fa13714edeb0e94..0000000000000000000000000000000000000000 --- a/tests/components/waze_travel_time/test_init.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Test Waze Travel Time initialization.""" -from homeassistant.components.waze_travel_time.const import DOMAIN -from homeassistant.helpers.entity_registry import async_get - -from tests.common import MockConfigEntry - - -async def test_migration(hass, bypass_platform_setup): - """Test migration logic for unique id.""" - config_entry = MockConfigEntry( - domain=DOMAIN, version=1, entry_id="test", unique_id="test" - ) - ent_reg = async_get(hass) - ent_entry = ent_reg.async_get_or_create( - "sensor", DOMAIN, unique_id="replaceable_unique_id", config_entry=config_entry - ) - entity_id = ent_entry.entity_id - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.unique_id is None - assert ent_reg.async_get(entity_id).unique_id == config_entry.entry_id diff --git a/tests/components/waze_travel_time/test_sensor.py b/tests/components/waze_travel_time/test_sensor.py index 67ba7c6e3115d508dc30d6ac2495419ba4b6174f..569335f9e6c6a10a3c72c8d169a88a4b4620ba3a 100644 --- a/tests/components/waze_travel_time/test_sensor.py +++ b/tests/components/waze_travel_time/test_sensor.py @@ -10,9 +10,10 @@ from homeassistant.components.waze_travel_time.const import ( CONF_REALTIME, CONF_UNITS, CONF_VEHICLE_TYPE, + DEFAULT_OPTIONS, DOMAIN, + IMPERIAL_UNITS, ) -from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL from .const import MOCK_CONFIG @@ -51,7 +52,7 @@ def mock_update_keyerror_fixture(mock_wrc): @pytest.mark.parametrize( "data,options", - [(MOCK_CONFIG, {})], + [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) @pytest.mark.usefixtures("mock_update", "mock_config") async def test_sensor(hass): @@ -84,7 +85,7 @@ async def test_sensor(hass): ( MOCK_CONFIG, { - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNITS: IMPERIAL_UNITS, CONF_REALTIME: True, CONF_VEHICLE_TYPE: "car", CONF_AVOID_TOLL_ROADS: True, @@ -105,7 +106,9 @@ async def test_imperial(hass): @pytest.mark.usefixtures("mock_update_wrcerror") async def test_sensor_failed_wrcerror(hass, caplog): """Test that sensor update fails with log message.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=DEFAULT_OPTIONS, entry_id="test" + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -117,7 +120,9 @@ async def test_sensor_failed_wrcerror(hass, caplog): @pytest.mark.usefixtures("mock_update_keyerror") async def test_sensor_failed_keyerror(hass, caplog): """Test that sensor update fails with log message.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=DEFAULT_OPTIONS, entry_id="test" + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 814d3b7857cfa34554b85490d7785b34fc660ff9..4b4f3a82d077a309e85eb768d87376ee13bcea3e 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -54,7 +54,7 @@ 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 homeassistant.util.temperature import convert as convert_temperature -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.testing_config.custom_components.test import weather as WeatherPlatform @@ -143,7 +143,7 @@ async def create_entity(hass: HomeAssistant, **kwargs): @pytest.mark.parametrize("native_unit", (TEMP_FAHRENHEIT, TEMP_CELSIUS)) @pytest.mark.parametrize( "state_unit, unit_system", - ((TEMP_CELSIUS, METRIC_SYSTEM), (TEMP_FAHRENHEIT, IMPERIAL_SYSTEM)), + ((TEMP_CELSIUS, METRIC_SYSTEM), (TEMP_FAHRENHEIT, US_CUSTOMARY_SYSTEM)), ) async def test_temperature( hass: HomeAssistant, @@ -176,7 +176,7 @@ async def test_temperature( @pytest.mark.parametrize("native_unit", (None,)) @pytest.mark.parametrize( "state_unit, unit_system", - ((TEMP_CELSIUS, METRIC_SYSTEM), (TEMP_FAHRENHEIT, IMPERIAL_SYSTEM)), + ((TEMP_CELSIUS, METRIC_SYSTEM), (TEMP_FAHRENHEIT, US_CUSTOMARY_SYSTEM)), ) async def test_temperature_no_unit( hass: HomeAssistant, @@ -209,7 +209,7 @@ async def test_temperature_no_unit( @pytest.mark.parametrize("native_unit", (PRESSURE_INHG, PRESSURE_INHG)) @pytest.mark.parametrize( "state_unit, unit_system", - ((PRESSURE_HPA, METRIC_SYSTEM), (PRESSURE_INHG, IMPERIAL_SYSTEM)), + ((PRESSURE_HPA, METRIC_SYSTEM), (PRESSURE_INHG, US_CUSTOMARY_SYSTEM)), ) async def test_pressure( hass: HomeAssistant, @@ -237,7 +237,7 @@ async def test_pressure( @pytest.mark.parametrize("native_unit", (None,)) @pytest.mark.parametrize( "state_unit, unit_system", - ((PRESSURE_HPA, METRIC_SYSTEM), (PRESSURE_INHG, IMPERIAL_SYSTEM)), + ((PRESSURE_HPA, METRIC_SYSTEM), (PRESSURE_INHG, US_CUSTOMARY_SYSTEM)), ) async def test_pressure_no_unit( hass: HomeAssistant, @@ -270,7 +270,7 @@ async def test_pressure_no_unit( "state_unit, unit_system", ( (SPEED_KILOMETERS_PER_HOUR, METRIC_SYSTEM), - (SPEED_MILES_PER_HOUR, IMPERIAL_SYSTEM), + (SPEED_MILES_PER_HOUR, US_CUSTOMARY_SYSTEM), ), ) async def test_wind_speed( @@ -304,7 +304,7 @@ async def test_wind_speed( "state_unit, unit_system", ( (SPEED_KILOMETERS_PER_HOUR, METRIC_SYSTEM), - (SPEED_MILES_PER_HOUR, IMPERIAL_SYSTEM), + (SPEED_MILES_PER_HOUR, US_CUSTOMARY_SYSTEM), ), ) async def test_wind_speed_no_unit( @@ -338,7 +338,7 @@ async def test_wind_speed_no_unit( "state_unit, unit_system", ( (LENGTH_KILOMETERS, METRIC_SYSTEM), - (LENGTH_MILES, IMPERIAL_SYSTEM), + (LENGTH_MILES, US_CUSTOMARY_SYSTEM), ), ) async def test_visibility( @@ -369,7 +369,7 @@ async def test_visibility( "state_unit, unit_system", ( (LENGTH_KILOMETERS, METRIC_SYSTEM), - (LENGTH_MILES, IMPERIAL_SYSTEM), + (LENGTH_MILES, US_CUSTOMARY_SYSTEM), ), ) async def test_visibility_no_unit( @@ -400,7 +400,7 @@ async def test_visibility_no_unit( "state_unit, unit_system", ( (LENGTH_MILLIMETERS, METRIC_SYSTEM), - (LENGTH_INCHES, IMPERIAL_SYSTEM), + (LENGTH_INCHES, US_CUSTOMARY_SYSTEM), ), ) async def test_precipitation( @@ -431,7 +431,7 @@ async def test_precipitation( "state_unit, unit_system", ( (LENGTH_MILLIMETERS, METRIC_SYSTEM), - (LENGTH_INCHES, IMPERIAL_SYSTEM), + (LENGTH_INCHES, US_CUSTOMARY_SYSTEM), ), ) async def test_precipitation_no_unit( @@ -719,7 +719,7 @@ async def test_backwards_compatibility_convert_values( precipitation_value = 1 precipitation_unit = LENGTH_MILLIMETERS - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM platform: WeatherPlatform = getattr(hass.components, "test.weather") platform.init(empty=True) diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index ef1998f734c7f2c5d680f7f0e984f4922ed8940a..7e113ba9b8329bc34e9173462ac009150151390b 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -15,7 +15,7 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes(hass: HomeAssistant, recorder_mock) -> None: +async def test_exclude_attributes(recorder_mock, hass: HomeAssistant) -> None: """Test weather attributes to be excluded.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}}) hass.config.units = METRIC_SYSTEM diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index 05f1be66d00cb94fa300ac670eca9c76c69b9d02..c8333c844472bf6a91fd5099f83e232e68c6a33e 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -39,6 +39,8 @@ def client_fixture(): client.sound_output = "speaker" client.muted = False client.is_on = True + client.is_registered = Mock(return_value=True) + client.is_connected = Mock(return_value=True) async def mock_state_update_callback(): await client.register_state_update_callback.call_args[0][0](client) diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..707f83b2fcf11cbdef9c3e0f15da9a2ced138203 --- /dev/null +++ b/tests/components/webostv/test_diagnostics.py @@ -0,0 +1,61 @@ +"""Tests for the diagnostics data provided by LG webOS Smart TV.""" +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from . import setup_webostv + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, hass_client: ClientSession, client +) -> None: + """Test diagnostics.""" + entry = await setup_webostv(hass) + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == { + "client": { + "is_registered": True, + "is_connected": True, + "current_app_id": "com.webos.app.livetv", + "current_channel": { + "channelId": "ch1id", + "channelName": "Channel 1", + "channelNumber": "1", + }, + "apps": { + "com.webos.app.livetv": { + "icon": REDACTED, + "id": "com.webos.app.livetv", + "largeIcon": REDACTED, + "title": "Live TV", + } + }, + "inputs": { + "in1": {"appId": "app0", "id": "in1", "label": "Input01"}, + "in2": {"appId": "app1", "id": "in2", "label": "Input02"}, + }, + "system_info": {"modelName": "TVFAKE"}, + "software_info": {"major_ver": "major", "minor_ver": "minor"}, + "hello_info": {"deviceUUID": "**REDACTED**"}, + "sound_output": "speaker", + "is_on": True, + }, + "entry": { + "entry_id": entry.entry_id, + "version": 1, + "domain": "webostv", + "title": "fake_webos", + "data": { + "client_secret": "**REDACTED**", + "host": "**REDACTED**", + }, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + }, + } diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 27ae21db6e2113283bf65878b76abafd394a0a8a..a865776f74df57a6d476d266c231f0b60ba3c2af 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -14,7 +14,7 @@ from homeassistant.components.websocket_api.auth import ( TYPE_AUTH_REQUIRED, ) from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL -from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATONS +from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity @@ -23,13 +23,7 @@ from homeassistant.helpers.json import json_loads from homeassistant.loader import async_get_integration from homeassistant.setup import DATA_SETUP_TIME, async_setup_component -from tests.common import ( - MockEntity, - MockEntityPlatform, - MockModule, - async_mock_service, - mock_integration, -) +from tests.common import MockEntity, MockEntityPlatform, async_mock_service STATE_KEY_SHORT_NAMES = { "entity_id": "e", @@ -1712,7 +1706,7 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( message = {"august": 12.5, "isy994": 12.8} - async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATONS, message) + async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, message) msg = await websocket_client.receive_json() assert msg["id"] == 7 assert msg["type"] == "event" @@ -1794,45 +1788,6 @@ async def test_validate_config_invalid(websocket_client, key, config, error): assert msg["result"] == {key: {"valid": False, "error": error}} -async def test_supported_brands(hass, websocket_client): - """Test supported brands.""" - # Custom components without supported brands that override a built-in component with - # supported brand will still be listed in HAS_SUPPORTED_BRANDS and should be ignored. - mock_integration( - hass, - MockModule("override_without_brands"), - ) - mock_integration( - hass, - MockModule("test", partial_manifest={"supported_brands": {"hello": "World"}}), - ) - mock_integration( - hass, - MockModule( - "abcd", partial_manifest={"supported_brands": {"something": "Something"}} - ), - ) - - with patch( - "homeassistant.generated.supported_brands.HAS_SUPPORTED_BRANDS", - ("abcd", "test", "override_without_brands"), - ): - await websocket_client.send_json({"id": 7, "type": "supported_brands"}) - msg = await websocket_client.receive_json() - - assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert msg["result"] == { - "abcd": { - "something": "Something", - }, - "test": { - "hello": "World", - }, - } - - async def test_message_coalescing(hass, websocket_client, hass_admin_user): """Test enabling message coalescing.""" await websocket_client.send_json( diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index fd9af99c1a48d340eb47a6bb533065aeb669ea83..8f2cd43fdb8b767b5965048164b314784b20ec67 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -1,8 +1,11 @@ """Test WebSocket Connection class.""" import asyncio import logging -from unittest.mock import Mock +from typing import Any +from unittest.mock import AsyncMock, Mock, patch +from aiohttp.test_utils import make_mocked_request +import pytest import voluptuous as vol from homeassistant import exceptions @@ -11,37 +14,86 @@ from homeassistant.components import websocket_api from tests.common import MockUser -async def test_exception_handling(): - """Test handling of exceptions.""" - send_messages = [] - user = MockUser() - refresh_token = Mock() - conn = websocket_api.ActiveConnection( - logging.getLogger(__name__), None, send_messages.append, user, refresh_token - ) - - for (exc, code, err) in ( - (exceptions.Unauthorized(), websocket_api.ERR_UNAUTHORIZED, "Unauthorized"), +@pytest.mark.parametrize( + "exc,code,err,log", + [ + ( + exceptions.Unauthorized(), + websocket_api.ERR_UNAUTHORIZED, + "Unauthorized", + "Error handling message: Unauthorized (unauthorized) from 127.0.0.42 (Browser)", + ), ( vol.Invalid("Invalid something"), websocket_api.ERR_INVALID_FORMAT, "Invalid something. Got {'id': 5}", + "Error handling message: Invalid something. Got {'id': 5} (invalid_format) from 127.0.0.42 (Browser)", + ), + ( + asyncio.TimeoutError(), + websocket_api.ERR_TIMEOUT, + "Timeout", + "Error handling message: Timeout (timeout) from 127.0.0.42 (Browser)", ), - (asyncio.TimeoutError(), websocket_api.ERR_TIMEOUT, "Timeout"), ( exceptions.HomeAssistantError("Failed to do X"), websocket_api.ERR_UNKNOWN_ERROR, "Failed to do X", + "Error handling message: Failed to do X (unknown_error) from 127.0.0.42 (Browser)", + ), + ( + ValueError("Really bad"), + websocket_api.ERR_UNKNOWN_ERROR, + "Unknown error", + "Error handling message: Unknown error (unknown_error) from 127.0.0.42 (Browser)", ), - (ValueError("Really bad"), websocket_api.ERR_UNKNOWN_ERROR, "Unknown error"), ( - exceptions.HomeAssistantError(), + exceptions.HomeAssistantError, websocket_api.ERR_UNKNOWN_ERROR, "Unknown error", + "Error handling message: Unknown error (unknown_error) from 127.0.0.42 (Browser)", ), - ): - send_messages.clear() + ], +) +async def test_exception_handling( + caplog: pytest.LogCaptureFixture, + exc: Exception, + code: str, + err: str, + log: str, +): + """Test handling of exceptions.""" + send_messages = [] + user = MockUser() + refresh_token = Mock() + current_request = AsyncMock() + + def get_extra_info(key: str) -> Any: + if key == "sslcontext": + return True + + if key == "peername": + return ("127.0.0.42", 8123) + + mocked_transport = Mock() + mocked_transport.get_extra_info = get_extra_info + mocked_request = make_mocked_request( + "GET", + "/api/websocket", + headers={"Host": "example.com", "User-Agent": "Browser"}, + transport=mocked_transport, + ) + + with patch( + "homeassistant.components.websocket_api.connection.current_request", + ) as current_request: + current_request.get.return_value = mocked_request + conn = websocket_api.ActiveConnection( + logging.getLogger(__name__), None, send_messages.append, user, refresh_token + ) + conn.async_handle_exception({"id": 5}, exc) - assert len(send_messages) == 1 - assert send_messages[0]["error"]["code"] == code - assert send_messages[0]["error"]["message"] == err + assert len(send_messages) == 1 + assert send_messages[0]["error"]["code"] == code + assert send_messages[0]["error"]["message"] == err + assert log in caplog.text diff --git a/tests/components/xiaomi_ble/__init__.py b/tests/components/xiaomi_ble/__init__.py index 4593e5c01f3e9142afc2c00fe40360c0b0d28c0c..ab88cc559b7b49cc5be5788677b4d25c11b1d8d0 100644 --- a/tests/components/xiaomi_ble/__init__.py +++ b/tests/components/xiaomi_ble/__init__.py @@ -1,10 +1,11 @@ """Tests for the SensorPush integration.""" from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from tests.components.bluetooth import generate_advertisement_data + NOT_SENSOR_PUSH_SERVICE_INFO = BluetoothServiceInfoBleak( name="Not it", address="00:00:00:00:00:00", @@ -14,7 +15,7 @@ NOT_SENSOR_PUSH_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={}, service_uuids=[], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -30,7 +31,7 @@ LYWSDCGQ_SERVICE_INFO = BluetoothServiceInfoBleak( }, service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -46,7 +47,7 @@ MMC_T201_1_SERVICE_INFO = BluetoothServiceInfoBleak( }, service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -62,7 +63,7 @@ JTYJGD03MI_SERVICE_INFO = BluetoothServiceInfoBleak( }, service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -78,7 +79,7 @@ YLKG07YL_SERVICE_INFO = BluetoothServiceInfoBleak( }, service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -94,7 +95,7 @@ MISSING_PAYLOAD_ENCRYPTED = BluetoothServiceInfoBleak( }, service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Not it"), + advertisement=generate_advertisement_data(local_name="Not it"), time=0, connectable=False, ) @@ -115,7 +116,7 @@ def make_advertisement( }, service_uuids=["0000fe95-0000-1000-8000-00805f9b34fb"], source="local", - advertisement=AdvertisementData(local_name="Test Device"), + advertisement=generate_advertisement_data(local_name="Test Device"), time=0, connectable=connectable, ) diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index e47a1a1ace550078a441549db60259708b57e93f..9d8a8b391671cf9542804766b5c9f01ad883d53e 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -9,7 +9,7 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.xiaomi_miio import const -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME, CONF_TOKEN +from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN from . import TEST_MAC @@ -67,7 +67,7 @@ TEST_CLOUD_DEVICES_2 = [ @pytest.fixture(name="xiaomi_miio_connect", autouse=True) def xiaomi_miio_connect_fixture(): - """Mock denonavr connection and entry setup.""" + """Mock miio connection and entry setup.""" mock_info = get_mock_info() with patch( @@ -320,6 +320,22 @@ async def test_config_flow_gateway_cloud_login_error(hass): assert result["step_id"] == "cloud" assert result["errors"] == {"base": "cloud_login_error"} + with patch( + "homeassistant.components.xiaomi_miio.config_flow.MiCloud.login", + side_effect=Exception({}), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + const.CONF_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_PASSWORD: TEST_CLOUD_PASS, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + async def test_config_flow_gateway_cloud_no_devices(hass): """Test a failed config flow using cloud with no devices.""" @@ -348,6 +364,22 @@ async def test_config_flow_gateway_cloud_no_devices(hass): assert result["step_id"] == "cloud" assert result["errors"] == {"base": "cloud_no_devices"} + with patch( + "homeassistant.components.xiaomi_miio.config_flow.MiCloud.get_devices", + side_effect=Exception({}), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + const.CONF_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_PASSWORD: TEST_CLOUD_PASS, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + async def test_config_flow_gateway_cloud_missing_token(hass): """Test a failed config flow using cloud with a missing token.""" @@ -558,34 +590,6 @@ async def test_config_flow_step_unknown_device(hass): assert result["errors"] == {"base": "unknown_device"} -async def test_import_flow_success(hass): - """Test a successful import form yaml for a device.""" - mock_info = get_mock_info(model=const.MODELS_SWITCH[0]) - - with patch( - "homeassistant.components.xiaomi_miio.device.Device.info", - return_value=mock_info, - ): - result = await hass.config_entries.flow.async_init( - const.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_NAME: TEST_NAME, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, - ) - - assert result["type"] == "create_entry" - assert result["title"] == TEST_NAME - assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, - const.CONF_CLOUD_USERNAME: None, - const.CONF_CLOUD_PASSWORD: None, - const.CONF_CLOUD_COUNTRY: None, - CONF_HOST: TEST_HOST, - CONF_TOKEN: TEST_TOKEN, - CONF_MODEL: const.MODELS_SWITCH[0], - const.CONF_MAC: TEST_MAC, - } - - async def test_config_flow_step_device_manual_model_error(hass): """Test config flow, device connection error, model None.""" result = await hass.config_entries.flow.async_init( @@ -618,6 +622,18 @@ async def test_config_flow_step_device_manual_model_error(hass): assert result["step_id"] == "connect" assert result["errors"] == {"base": "cannot_connect"} + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + side_effect=Exception({}), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_MODEL: TEST_MODEL}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + async def test_config_flow_step_device_manual_model_succes(hass): """Test config flow, device connection error, manual model.""" @@ -724,7 +740,7 @@ async def config_flow_device_success(hass, model_to_test): async def config_flow_generic_roborock(hass): """Test a successful config flow for a generic roborock vacuum.""" - DUMMY_MODEL = "roborock.vacuum.dummy" + dummy_model = "roborock.vacuum.dummy" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -743,7 +759,7 @@ async def config_flow_generic_roborock(hass): assert result["step_id"] == "manual" assert result["errors"] == {} - mock_info = get_mock_info(model=DUMMY_MODEL) + mock_info = get_mock_info(model=dummy_model) with patch( "homeassistant.components.xiaomi_miio.device.Device.info", @@ -755,7 +771,7 @@ async def config_flow_generic_roborock(hass): ) assert result["type"] == "create_entry" - assert result["title"] == DUMMY_MODEL + assert result["title"] == dummy_model assert result["data"] == { const.CONF_FLOW_TYPE: const.CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, @@ -763,7 +779,7 @@ async def config_flow_generic_roborock(hass): const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, - CONF_MODEL: DUMMY_MODEL, + CONF_MODEL: dummy_model, const.CONF_MAC: TEST_MAC, } diff --git a/tests/components/yalexs_ble/__init__.py b/tests/components/yalexs_ble/__init__.py index 36002a49f3eed618cf46ce17a272c3b6627f57fe..200200c0a0bc25b3c4957df2e84b35552944a44b 100644 --- a/tests/components/yalexs_ble/__init__.py +++ b/tests/components/yalexs_ble/__init__.py @@ -1,9 +1,10 @@ """Tests for the Yale Access Bluetooth integration.""" from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from tests.components.bluetooth import generate_advertisement_data + YALE_ACCESS_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="M1012LU", address="AA:BB:CC:DD:EE:FF", @@ -16,7 +17,7 @@ YALE_ACCESS_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) @@ -34,7 +35,7 @@ LOCK_DISCOVERY_INFO_UUID_ADDRESS = BluetoothServiceInfoBleak( service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) @@ -51,7 +52,7 @@ OLD_FIRMWARE_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) @@ -69,7 +70,7 @@ NOT_YALE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_data={}, source="local", device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), - advertisement=AdvertisementData(), + advertisement=generate_advertisement_data(), time=0, connectable=True, ) diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index a3fb0cf621168c8d36f34d5f4582db6d024a8952..b516bfe384334658f79881464be3aa26daebce50 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -21,6 +21,10 @@ async def silent_ssdp_scanner(hass): "homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners" ), patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), patch( "homeassistant.components.ssdp.Scanner.async_scan" + ), patch( + "homeassistant.components.ssdp.Server._async_start_upnp_servers" + ), patch( + "homeassistant.components.ssdp.Server._async_stop_upnp_servers" ): yield diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index fad39a052d5e73dd1a68b66ce241d30d81052802..f38705c6e9ac511492bc73af83093abbde7c0162 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -861,14 +861,16 @@ async def test_device_types(hass: HomeAssistant, caplog): await hass.async_block_till_done() bright = round(255 * int(PROPERTIES["bright"]) / 100) - ct = color_temperature_kelvin_to_mired(int(PROPERTIES["ct"])) + ct = int(PROPERTIES["ct"]) + ct_mired = color_temperature_kelvin_to_mired(int(PROPERTIES["ct"])) hue = int(PROPERTIES["hue"]) sat = int(PROPERTIES["sat"]) rgb = int(PROPERTIES["rgb"]) rgb_color = ((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF) hs_color = (hue, sat) bg_bright = round(255 * int(PROPERTIES["bg_bright"]) / 100) - bg_ct = color_temperature_kelvin_to_mired(int(PROPERTIES["bg_ct"])) + bg_ct = int(PROPERTIES["bg_ct"]) + bg_ct_kelvin = color_temperature_kelvin_to_mired(int(PROPERTIES["bg_ct"])) bg_hue = int(PROPERTIES["bg_hue"]) bg_sat = int(PROPERTIES["bg_sat"]) bg_rgb = int(PROPERTIES["bg_rgb"]) @@ -911,6 +913,10 @@ async def test_device_types(hass: HomeAssistant, caplog): { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -918,7 +924,8 @@ async def test_device_types(hass: HomeAssistant, caplog): model_specs["color_temp"]["min"] ), "brightness": bright, - "color_temp": ct, + "color_temp_kelvin": ct, + "color_temp": ct_mired, "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], "hs_color": (26.812, 34.87), @@ -936,6 +943,10 @@ async def test_device_types(hass: HomeAssistant, caplog): "hs_color": (28.401, 100.0), "rgb_color": (255, 120, 0), "xy_color": (0.621, 0.367), + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -945,6 +956,7 @@ async def test_device_types(hass: HomeAssistant, caplog): "brightness": nl_br, "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], + "color_temp_kelvin": model_specs["color_temp"]["min"], "color_temp": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), @@ -960,6 +972,10 @@ async def test_device_types(hass: HomeAssistant, caplog): { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -989,6 +1005,10 @@ async def test_device_types(hass: HomeAssistant, caplog): { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1019,6 +1039,10 @@ async def test_device_types(hass: HomeAssistant, caplog): { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1046,6 +1070,10 @@ async def test_device_types(hass: HomeAssistant, caplog): { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1072,6 +1100,10 @@ async def test_device_types(hass: HomeAssistant, caplog): { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": model_specs["color_temp"]["min"], + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1097,6 +1129,12 @@ async def test_device_types(hass: HomeAssistant, caplog): { "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) + ), + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1104,7 +1142,8 @@ async def test_device_types(hass: HomeAssistant, caplog): model_specs["color_temp"]["min"] ), "brightness": bright, - "color_temp": ct, + "color_temp_kelvin": ct, + "color_temp": ct_mired, "color_mode": "color_temp", "supported_color_modes": ["color_temp"], "hs_color": (26.812, 34.87), @@ -1120,6 +1159,12 @@ async def test_device_types(hass: HomeAssistant, caplog): nightlight_mode_properties={ "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) + ), + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1127,6 +1172,9 @@ async def test_device_types(hass: HomeAssistant, caplog): model_specs["color_temp"]["min"] ), "brightness": nl_br, + "color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) + ), "color_temp": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), @@ -1151,6 +1199,12 @@ async def test_device_types(hass: HomeAssistant, caplog): "flowing": False, "night_light": True, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) + ), + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1158,7 +1212,8 @@ async def test_device_types(hass: HomeAssistant, caplog): model_specs["color_temp"]["min"] ), "brightness": bright, - "color_temp": ct, + "color_temp_kelvin": ct, + "color_temp": ct_mired, "color_mode": "color_temp", "supported_color_modes": ["color_temp"], "hs_color": (26.812, 34.87), @@ -1177,6 +1232,12 @@ async def test_device_types(hass: HomeAssistant, caplog): "flowing": False, "night_light": True, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) + ), + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["max"]) + ), "min_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["max"] ), @@ -1184,6 +1245,9 @@ async def test_device_types(hass: HomeAssistant, caplog): model_specs["color_temp"]["min"] ), "brightness": nl_br, + "color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) + ), "color_temp": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), @@ -1202,10 +1266,15 @@ async def test_device_types(hass: HomeAssistant, caplog): { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": 1700, + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(6500) + ), "min_mireds": color_temperature_kelvin_to_mired(6500), "max_mireds": color_temperature_kelvin_to_mired(1700), "brightness": bg_bright, - "color_temp": bg_ct, + "color_temp_kelvin": bg_ct, + "color_temp": bg_ct_kelvin, "color_mode": "color_temp", "supported_color_modes": ["color_temp", "hs", "rgb"], "hs_color": (27.001, 19.243), @@ -1224,6 +1293,10 @@ async def test_device_types(hass: HomeAssistant, caplog): { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": 1700, + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(6500) + ), "min_mireds": color_temperature_kelvin_to_mired(6500), "max_mireds": color_temperature_kelvin_to_mired(1700), "brightness": bg_bright, @@ -1245,6 +1318,10 @@ async def test_device_types(hass: HomeAssistant, caplog): { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "min_color_temp_kelvin": 1700, + "max_color_temp_kelvin": color_temperature_mired_to_kelvin( + color_temperature_kelvin_to_mired(6500) + ), "min_mireds": color_temperature_kelvin_to_mired(6500), "max_mireds": color_temperature_kelvin_to_mired(1700), "brightness": bg_bright, diff --git a/tests/components/zamg/__init__.py b/tests/components/zamg/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9c6415d7f84803843aeaaa36d1510458d64b8bb0 --- /dev/null +++ b/tests/components/zamg/__init__.py @@ -0,0 +1 @@ +"""Tests for the ZAMG component.""" diff --git a/tests/components/zamg/conftest.py b/tests/components/zamg/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..62ef191cb48366a66e919eb33d394b7b128821fa --- /dev/null +++ b/tests/components/zamg/conftest.py @@ -0,0 +1,95 @@ +"""Fixtures for Zamg integration tests.""" +from collections.abc import Generator +import json +from unittest.mock import MagicMock, patch + +import pytest +from zamg import ZamgData as ZamgDevice + +from homeassistant.components.zamg.const import CONF_STATION_ID, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + +TEST_STATION_ID = "11240" +TEST_STATION_NAME = "Graz/Flughafen" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_STATION_ID: TEST_STATION_ID}, + unique_id=TEST_STATION_ID, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None, None, None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.zamg.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_zamg_config_flow( + request: pytest.FixtureRequest, +) -> Generator[None, MagicMock, None]: + """Return a mocked Zamg client.""" + with patch( + "homeassistant.components.zamg.sensor.ZamgData", autospec=True + ) as zamg_mock: + zamg = zamg_mock.return_value + zamg.update.return_value = ZamgDevice( + json.loads(load_fixture("zamg/data.json")) + ) + zamg.get_data.return_value = zamg.get_data(TEST_STATION_ID) + yield zamg + + +@pytest.fixture +def mock_zamg(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: + """Return a mocked Zamg client.""" + + with patch( + "homeassistant.components.zamg.config_flow.ZamgData", autospec=True + ) as zamg_mock: + zamg = zamg_mock.return_value + zamg.update.return_value = {TEST_STATION_ID: {"Name": TEST_STATION_NAME}} + zamg.zamg_stations.return_value = { + TEST_STATION_ID: (46.99305556, 15.43916667, TEST_STATION_NAME), + "11244": (46.8722229, 15.90361118, "BAD GLEICHENBERG"), + } + zamg.closest_station.return_value = TEST_STATION_ID + zamg.get_data.return_value = TEST_STATION_ID + zamg.get_station_name = TEST_STATION_NAME + yield zamg + + +@pytest.fixture +def mock_zamg_stations( + request: pytest.FixtureRequest, +) -> Generator[None, MagicMock, None]: + """Return a mocked Zamg client.""" + with patch( + "homeassistant.components.zamg.config_flow.ZamgData.zamg_stations" + ) as zamg_mock: + zamg_mock.return_value = { + "11240": (46.99305556, 15.43916667, "GRAZ-FLUGHAFEN"), + "11244": (46.87222222, 15.90361111, "BAD GLEICHENBERG"), + } + yield zamg_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, +) -> MockConfigEntry: + """Set up the Zamg integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/zamg/fixtures/data.json b/tests/components/zamg/fixtures/data.json new file mode 100644 index 0000000000000000000000000000000000000000..2f0f3329b4402e409e072308091b61463303048b --- /dev/null +++ b/tests/components/zamg/fixtures/data.json @@ -0,0 +1,6 @@ +{ + "data": { + "station_id": "11240", + "station_name": "Graz/Flughafen" + } +} diff --git a/tests/components/zamg/test_config_flow.py b/tests/components/zamg/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..dc2eb62f1b957d0b27e2c25150abd6e7e3f0052e --- /dev/null +++ b/tests/components/zamg/test_config_flow.py @@ -0,0 +1,194 @@ +"""Tests for the Zamg config flow.""" +from unittest.mock import MagicMock + +from homeassistant.components.zamg.const import CONF_STATION_ID, DOMAIN, LOGGER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_STATION_ID, TEST_STATION_NAME + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + LOGGER.debug(result) + assert result.get("data_schema") != "" + assert "flow_id" in result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_ID: int(TEST_STATION_ID)}, + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_STATION_ID] == TEST_STATION_ID + assert "result" in result + assert result["result"].unique_id == TEST_STATION_ID + + +async def test_error_update( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test with error of reading from Zamg.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + LOGGER.debug(result) + assert result.get("data_schema") != "" + mock_zamg.update.side_effect = ValueError + assert "flow_id" in result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_ID: int(TEST_STATION_ID)}, + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "cannot_connect" + + +async def test_full_import_flow_implementation( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test the full import flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_STATION_ID: TEST_STATION_ID, CONF_NAME: TEST_STATION_NAME}, + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("data") == {CONF_STATION_ID: TEST_STATION_ID} + + +async def test_user_flow_duplicate( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + assert "flow_id" in result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_ID: int(TEST_STATION_ID)}, + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_STATION_ID] == TEST_STATION_ID + assert "result" in result + assert result["result"].unique_id == TEST_STATION_ID + # try to add another instance + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_ID: int(TEST_STATION_ID)}, + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_import_flow_duplicate( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test import flow with duplicate entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + assert "flow_id" in result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_ID: int(TEST_STATION_ID)}, + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_STATION_ID] == TEST_STATION_ID + assert "result" in result + assert result["result"].unique_id == TEST_STATION_ID + # try to add another instance + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_STATION_ID: TEST_STATION_ID, CONF_NAME: TEST_STATION_NAME}, + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_import_flow_duplicate_after_position( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test import flow with duplicate entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + assert "flow_id" in result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_STATION_ID: int(TEST_STATION_ID)}, + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_STATION_ID] == TEST_STATION_ID + assert "result" in result + assert result["result"].unique_id == TEST_STATION_ID + # try to add another instance + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_STATION_ID: "123", CONF_NAME: TEST_STATION_NAME}, + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_import_flow_no_name( + hass: HomeAssistant, + mock_zamg: MagicMock, + mock_setup_entry: None, +) -> None: + """Test the full import flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_STATION_ID: TEST_STATION_ID}, + ) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("data") == {CONF_STATION_ID: TEST_STATION_ID} diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 0de9929fcf82a1ab92626ed201ee643abb91715a..039672b9955a19860b90266c8102d7be67e4aacc 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -13,12 +13,13 @@ from homeassistant.components.zeroconf import ( _get_announced_addresses, ) from homeassistant.const import ( + EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.generated import zeroconf as zc_gen -from homeassistant.setup import async_setup_component +from homeassistant.setup import ATTR_COMPONENT, async_setup_component NON_UTF8_VALUE = b"ABCDEF\x8a" NON_ASCII_KEY = b"non-ascii-key\x8a" @@ -1159,3 +1160,32 @@ async def test_no_name(hass, mock_async_zeroconf): register_call = mock_async_zeroconf.async_register_service.mock_calls[-1] info = register_call.args[0] assert info.name == "Home._home-assistant._tcp.local." + + +async def test_setup_with_disallowed_characters_in_local_name( + hass, mock_async_zeroconf, caplog +): + """Test we still setup with disallowed characters in the location name.""" + with patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + ), patch.object( + hass.config, + "location_name", + "My.House", + ): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + calls = mock_async_zeroconf.async_register_service.mock_calls + assert calls[0][1][0].name == "My House._home-assistant._tcp.local." + + +async def test_start_with_frontend(hass, mock_async_zeroconf): + """Test we start with the frontend.""" + with patch("homeassistant.components.zeroconf.HaZeroconf"): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: "frontend"}) + await hass.async_block_till_done() + + mock_async_zeroconf.async_register_service.assert_called_once() diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index e745856c34200a26590710c98fe9d891b2028216..584abbaecdbc881be140a51ebeedf2045d3ba1a6 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -125,28 +125,28 @@ async def test_get_actions(hass, device_ias): "domain": Platform.SELECT, "type": "select_option", "device_id": reg_device.id, - "entity_id": "select.fakemanufacturer_fakemodel_defaulttoneselect", + "entity_id": "select.fakemanufacturer_fakemodel_default_siren_tone", "metadata": {"secondary": True}, }, { "domain": Platform.SELECT, "type": "select_option", "device_id": reg_device.id, - "entity_id": "select.fakemanufacturer_fakemodel_defaultsirenlevelselect", + "entity_id": "select.fakemanufacturer_fakemodel_default_siren_level", "metadata": {"secondary": True}, }, { "domain": Platform.SELECT, "type": "select_option", "device_id": reg_device.id, - "entity_id": "select.fakemanufacturer_fakemodel_defaultstrobelevelselect", + "entity_id": "select.fakemanufacturer_fakemodel_default_strobe_level", "metadata": {"secondary": True}, }, { "domain": Platform.SELECT, "type": "select_option", "device_id": reg_device.id, - "entity_id": "select.fakemanufacturer_fakemodel_defaultstrobeselect", + "entity_id": "select.fakemanufacturer_fakemodel_default_strobe", "metadata": {"secondary": True}, }, ] @@ -183,7 +183,7 @@ async def test_get_inovelli_actions(hass, device_inovelli): { "device_id": inovelli_reg_device.id, "domain": Platform.BUTTON, - "entity_id": "button.inovelli_vzm31_sn_identifybutton", + "entity_id": "button.inovelli_vzm31_sn_identify", "metadata": {"secondary": True}, "type": "press", }, diff --git a/tests/components/zha/test_helpers.py b/tests/components/zha/test_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..64f8c732ca98eff13f42b9bac5e6ba2df491ab9d --- /dev/null +++ b/tests/components/zha/test_helpers.py @@ -0,0 +1,211 @@ +"""Tests for ZHA helpers.""" +import logging +from unittest.mock import patch + +import pytest +import voluptuous_serialize +import zigpy.profiles.zha as zha +from zigpy.types.basic import uint16_t +import zigpy.zcl.clusters.general as general +import zigpy.zcl.clusters.lighting as lighting + +from homeassistant.components.zha.core.helpers import ( + cluster_command_schema_to_vol_schema, + convert_to_zcl_values, +) +from homeassistant.const import Platform +import homeassistant.helpers.config_validation as cv + +from .common import async_enable_traffic +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +_LOGGER = logging.getLogger(__name__) + + +@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.BUTTON, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + +@pytest.fixture +async def device_light(hass, zigpy_device_mock, zha_device_joined): + """Test light.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + lighting.Color.cluster_id, + general.Groups.cluster_id, + general.Identify.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + } + ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature + | lighting.Color.ColorCapabilities.XY_attributes + } + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return color_cluster, zha_device + + +async def test_zcl_schema_conversions(hass, device_light): + """Test ZHA ZCL schema conversion helpers.""" + color_cluster, zha_device = device_light + await async_enable_traffic(hass, [zha_device]) + command_schema = color_cluster.commands_by_name["color_loop_set"].schema + expected_schema = [ + { + "type": "multi_select", + "options": ["Action", "Direction", "Time", "Start Hue"], + "name": "update_flags", + "required": True, + }, + { + "type": "select", + "options": [ + ("Deactivate", "Deactivate"), + ("Activate from color loop hue", "Activate from color loop hue"), + ("Activate from current hue", "Activate from current hue"), + ], + "name": "action", + "required": True, + }, + { + "type": "select", + "options": [("Decrement", "Decrement"), ("Increment", "Increment")], + "name": "direction", + "required": True, + }, + { + "type": "integer", + "valueMin": 0, + "valueMax": 65535, + "name": "time", + "required": True, + }, + { + "type": "integer", + "valueMin": 0, + "valueMax": 65535, + "name": "start_hue", + "required": True, + }, + { + "type": "integer", + "valueMin": 0, + "valueMax": 255, + "name": "options_mask", + "optional": True, + }, + { + "type": "integer", + "valueMin": 0, + "valueMax": 255, + "name": "options_override", + "optional": True, + }, + ] + vol_schema = voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(command_schema), + custom_serializer=cv.custom_serializer, + ) + assert vol_schema == expected_schema + + raw_data = { + "update_flags": ["Action", "Start Hue"], + "action": "Activate from current hue", + "direction": "Increment", + "time": 20, + "start_hue": 196, + } + + converted_data = convert_to_zcl_values(raw_data, command_schema) + + assert isinstance( + converted_data["update_flags"], lighting.Color.ColorLoopUpdateFlags + ) + assert lighting.Color.ColorLoopUpdateFlags.Action in converted_data["update_flags"] + assert ( + lighting.Color.ColorLoopUpdateFlags.Start_Hue in converted_data["update_flags"] + ) + + assert isinstance(converted_data["action"], lighting.Color.ColorLoopAction) + assert ( + converted_data["action"] + == lighting.Color.ColorLoopAction.Activate_from_current_hue + ) + + assert isinstance(converted_data["direction"], lighting.Color.ColorLoopDirection) + assert converted_data["direction"] == lighting.Color.ColorLoopDirection.Increment + + assert isinstance(converted_data["time"], uint16_t) + assert converted_data["time"] == 20 + + assert isinstance(converted_data["start_hue"], uint16_t) + assert converted_data["start_hue"] == 196 + + raw_data = { + "update_flags": [0b0000_0001, 0b0000_1000], + "action": 0x02, + "direction": 0x01, + "time": 20, + "start_hue": 196, + } + + converted_data = convert_to_zcl_values(raw_data, command_schema) + + assert isinstance( + converted_data["update_flags"], lighting.Color.ColorLoopUpdateFlags + ) + assert lighting.Color.ColorLoopUpdateFlags.Action in converted_data["update_flags"] + assert ( + lighting.Color.ColorLoopUpdateFlags.Start_Hue in converted_data["update_flags"] + ) + + assert isinstance(converted_data["action"], lighting.Color.ColorLoopAction) + assert ( + converted_data["action"] + == lighting.Color.ColorLoopAction.Activate_from_current_hue + ) + + assert isinstance(converted_data["direction"], lighting.Color.ColorLoopDirection) + assert converted_data["direction"] == lighting.Color.ColorLoopDirection.Increment + + assert isinstance(converted_data["time"], uint16_t) + assert converted_data["time"] == 20 + + assert isinstance(converted_data["start_hue"], uint16_t) + assert converted_data["start_hue"] == 196 + + # This time, the update flags bitmap is empty + raw_data = { + "update_flags": [], + "action": 0x02, + "direction": 0x01, + "time": 20, + "start_hue": 196, + } + + converted_data = convert_to_zcl_values(raw_data, command_schema) + + # No flags are passed through + assert converted_data["update_flags"] == 0 diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index 0bb620e98f49aab926c2c595a06086502925a79f..219c77f76d7fa3119f698cf7eb89f41b01cc25af 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -5,6 +5,7 @@ import pytest from zigpy.exceptions import ZigbeeException from zigpy.profiles import zha import zigpy.zcl.clusters.general as general +import zigpy.zcl.clusters.lighting as lighting import zigpy.zcl.foundation as zcl_f from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN @@ -64,12 +65,13 @@ async def light(zigpy_device_mock): { 1: { SIG_EP_PROFILE: zha.PROFILE_ID, - SIG_EP_TYPE: zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, SIG_EP_INPUT: [ general.Basic.cluster_id, general.Identify.cluster_id, general.OnOff.cluster_id, general.LevelControl.cluster_id, + lighting.Color.cluster_id, ], SIG_EP_OUTPUT: [general.Ota.cluster_id], } @@ -211,7 +213,7 @@ async def test_level_control_number( Platform.NUMBER, zha_device, hass, - qualifier=attr.replace("_", ""), + qualifier=attr, ) assert entity_id is not None @@ -322,3 +324,113 @@ async def test_level_control_number( attr: new_value, } assert hass.states.get(entity_id).state == str(initial_value) + + +@pytest.mark.parametrize( + "attr, initial_value, new_value", + (("start_up_color_temperature", 500, 350),), +) +async def test_color_number( + hass, light, zha_device_joined, attr, initial_value, new_value +): + """Test zha color number entities - new join.""" + + entity_registry = er.async_get(hass) + color_cluster = light.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + attr: initial_value, + } + zha_device = await zha_device_joined(light) + + entity_id = await find_entity_id( + Platform.NUMBER, + zha_device, + hass, + qualifier=attr, + ) + assert entity_id is not None + + assert color_cluster.read_attributes.call_count == 3 + assert ( + call( + [ + "color_temp_physical_min", + "color_temp_physical_max", + "color_capabilities", + "start_up_color_temperature", + ], + allow_cache=True, + only_cache=False, + manufacturer=None, + ) + in color_cluster.read_attributes.call_args_list + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == str(initial_value) + + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + + # Test number set_value + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": entity_id, + "value": new_value, + }, + blocking=True, + ) + + assert color_cluster.write_attributes.call_count == 1 + assert color_cluster.write_attributes.call_args[0][0] == { + attr: new_value, + } + + state = hass.states.get(entity_id) + assert state + assert state.state == str(new_value) + + color_cluster.read_attributes.reset_mock() + 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 + ) + # the mocking doesn't update the attr cache so this flips back to initial value + assert hass.states.get(entity_id).state == str(initial_value) + assert color_cluster.read_attributes.call_count == 1 + assert ( + call( + [ + attr, + ], + allow_cache=False, + only_cache=False, + manufacturer=None, + ) + in color_cluster.read_attributes.call_args_list + ) + + color_cluster.write_attributes.reset_mock() + color_cluster.write_attributes.side_effect = ZigbeeException + + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": entity_id, + "value": new_value, + }, + blocking=True, + ) + + assert color_cluster.write_attributes.call_count == 1 + assert color_cluster.write_attributes.call_args[0][0] == { + attr: new_value, + } + assert hass.states.get(entity_id).state == str(initial_value) diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py index b9c7297582366b19adef48821f4dbbf51067a753..e9a7f476efb9cc8b476fe39433ca515b397075ef 100644 --- a/tests/components/zha/test_select.py +++ b/tests/components/zha/test_select.py @@ -163,7 +163,7 @@ async def test_select_restore_state( ): """Test zha select entity restore state.""" - entity_id = "select.fakemanufacturer_fakemodel_defaulttoneselect" + entity_id = "select.fakemanufacturer_fakemodel_default_siren_tone" core_rs(entity_id, state="Burglar") zigpy_device = zigpy_device_mock( @@ -202,12 +202,12 @@ async def test_on_off_select_new_join(hass, light, zha_device_joined): "start_up_on_off": general.OnOff.StartUpOnOff.On } zha_device = await zha_device_joined(light) - select_name = general.OnOff.StartUpOnOff.__name__ + select_name = "start_up_behavior" entity_id = await find_entity_id( Platform.SELECT, zha_device, hass, - qualifier=select_name.lower(), + qualifier=select_name, ) assert entity_id is not None @@ -285,12 +285,12 @@ async def test_on_off_select_restored(hass, light, zha_device_restored): in on_off_cluster.read_attributes.call_args_list ) - select_name = general.OnOff.StartUpOnOff.__name__ + select_name = "start_up_behavior" entity_id = await find_entity_id( Platform.SELECT, zha_device, hass, - qualifier=select_name.lower(), + qualifier=select_name, ) assert entity_id is not None diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 0698c07db9e5ed8f5881c49cbd96ba4e13bd9d28..55ea9833caaeef6ea044d684a76f9d276f1a1ba1 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -309,7 +309,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( smartenergy.Metering.cluster_id, - "smartenergy_metering", + "instantaneous_demand", async_test_metering, 1, { @@ -323,7 +323,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( smartenergy.Metering.cluster_id, - "smartenergy_summation", + "summation_delivered", async_test_smart_energy_summation, 1, { @@ -339,7 +339,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "electrical_measurement", + "active_power", async_test_electrical_measurement, 7, {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, @@ -347,7 +347,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "electrical_measurement_apparent_power", + "apparent_power", async_test_em_apparent_power, 7, {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, @@ -355,7 +355,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "electrical_measurement_rms_current", + "rms_current", async_test_em_rms_current, 7, {"ac_current_divisor": 1000, "ac_current_multiplier": 1}, @@ -363,7 +363,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "electrical_measurement_rms_voltage", + "rms_voltage", async_test_em_rms_voltage, 7, {"ac_voltage_divisor": 10, "ac_voltage_multiplier": 1}, @@ -437,7 +437,7 @@ async def test_sensor( zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 cluster.PLUGGED_ATTR_READS = read_plug zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = ENTITY_ID_PREFIX.format(entity_suffix.replace("_", "")) + entity_id = ENTITY_ID_PREFIX.format(entity_suffix) await async_enable_traffic(hass, [zha_device], enabled=False) await hass.async_block_till_done() @@ -642,37 +642,37 @@ async def test_electrical_measurement_init( homeautomation.ElectricalMeasurement.cluster_id, {"apparent_power", "rms_voltage", "rms_current"}, { - "electrical_measurement", - "electrical_measurement_frequency", - "electrical_measurement_power_factor", + "active_power", + "ac_frequency", + "power_factor", }, { - "electrical_measurement_apparent_power", - "electrical_measurement_rms_voltage", - "electrical_measurement_rms_current", + "apparent_power", + "rms_voltage", + "rms_current", }, ), ( homeautomation.ElectricalMeasurement.cluster_id, {"apparent_power", "rms_current", "ac_frequency", "power_factor"}, - {"electrical_measurement_rms_voltage", "electrical_measurement"}, + {"rms_voltage", "active_power"}, { - "electrical_measurement_apparent_power", - "electrical_measurement_rms_current", - "electrical_measurement_frequency", - "electrical_measurement_power_factor", + "apparent_power", + "rms_current", + "ac_frequency", + "power_factor", }, ), ( homeautomation.ElectricalMeasurement.cluster_id, set(), { - "electrical_measurement_rms_voltage", - "electrical_measurement", - "electrical_measurement_apparent_power", - "electrical_measurement_rms_current", - "electrical_measurement_frequency", - "electrical_measurement_power_factor", + "rms_voltage", + "active_power", + "apparent_power", + "rms_current", + "ac_frequency", + "power_factor", }, set(), ), @@ -682,10 +682,10 @@ async def test_electrical_measurement_init( "instantaneous_demand", }, { - "smartenergy_summation", + "summation_delivered", }, { - "smartenergy_metering", + "instantaneous_demand", }, ), ( @@ -693,16 +693,16 @@ async def test_electrical_measurement_init( {"instantaneous_demand", "current_summ_delivered"}, {}, { - "smartenergy_summation", - "smartenergy_metering", + "summation_delivered", + "instantaneous_demand", }, ), ( smartenergy.Metering.cluster_id, {}, { - "smartenergy_summation", - "smartenergy_metering", + "summation_delivered", + "instantaneous_demand", }, {}, ), @@ -719,10 +719,8 @@ async def test_unsupported_attributes_sensor( ): """Test zha sensor platform.""" - entity_ids = {ENTITY_ID_PREFIX.format(e.replace("_", "")) for e in entity_ids} - missing_entity_ids = { - ENTITY_ID_PREFIX.format(e.replace("_", "")) for e in missing_entity_ids - } + entity_ids = {ENTITY_ID_PREFIX.format(e) for e in entity_ids} + missing_entity_ids = {ENTITY_ID_PREFIX.format(e) for e in missing_entity_ids} zigpy_device = zigpy_device_mock( { @@ -836,7 +834,7 @@ async def test_se_summation_uom( ): """Test zha smart energy summation.""" - entity_id = ENTITY_ID_PREFIX.format("smartenergysummation") + entity_id = ENTITY_ID_PREFIX.format("summation_delivered") zigpy_device = zigpy_device_mock( { 1: { @@ -890,7 +888,7 @@ async def test_elec_measurement_sensor_type( ): """Test zha electrical measurement sensor type.""" - entity_id = ENTITY_ID_PREFIX.format("electricalmeasurement") + entity_id = ENTITY_ID_PREFIX.format("active_power") zigpy_dev = elec_measurement_zigpy_dev zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS[ "measurement_type" @@ -939,7 +937,7 @@ async def test_elec_measurement_skip_unsupported_attribute( ): """Test zha electrical measurement skipping update of unsupported attributes.""" - entity_id = ENTITY_ID_PREFIX.format("electricalmeasurement") + entity_id = ENTITY_ID_PREFIX.format("active_power") zha_dev = elec_measurement_zha_dev cluster = zha_dev.device.endpoints[1].electrical_measurement diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 2d15f9335dbcec18b45ac0cd0728956de57ca3f6..caa3da9ceef0f2a3b8642c09ccaeb295f93d7b59 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -38,7 +38,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008"], DEV_SIG_ENTITIES: [ - "button.adurolight_adurolight_ncc_identifybutton", + "button.adurolight_adurolight_ncc_identify", "sensor.adurolight_adurolight_ncc_rssi", "sensor.adurolight_adurolight_ncc_lqi", ], @@ -46,7 +46,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.adurolight_adurolight_ncc_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.adurolight_adurolight_ncc_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -76,7 +76,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["5:0x0019"], DEV_SIG_ENTITIES: [ - "button.bosch_isw_zpr1_wp13_identifybutton", + "button.bosch_isw_zpr1_wp13_identify", "sensor.bosch_isw_zpr1_wp13_battery", "sensor.bosch_isw_zpr1_wp13_temperature", "binary_sensor.bosch_isw_zpr1_wp13_iaszone", @@ -92,7 +92,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-5-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.bosch_isw_zpr1_wp13_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.bosch_isw_zpr1_wp13_identify", }, ("sensor", "00:11:22:33:44:55:66:77-5-1"): { DEV_SIG_CHANNELS: ["power"], @@ -132,7 +132,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_3130_identifybutton", + "button.centralite_3130_identify", "sensor.centralite_3130_battery", "sensor.centralite_3130_rssi", "sensor.centralite_3130_lqi", @@ -141,7 +141,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_3130_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3130_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -176,15 +176,15 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_3210_l_identifybutton", - "sensor.centralite_3210_l_electricalmeasurement", - "sensor.centralite_3210_l_electricalmeasurementapparentpower", - "sensor.centralite_3210_l_electricalmeasurementrmscurrent", - "sensor.centralite_3210_l_electricalmeasurementrmsvoltage", - "sensor.centralite_3210_l_electricalmeasurementfrequency", - "sensor.centralite_3210_l_electricalmeasurementpowerfactor", - "sensor.centralite_3210_l_smartenergymetering", - "sensor.centralite_3210_l_smartenergysummation", + "button.centralite_3210_l_identify", + "sensor.centralite_3210_l_active_power", + "sensor.centralite_3210_l_apparent_power", + "sensor.centralite_3210_l_rms_current", + "sensor.centralite_3210_l_rms_voltage", + "sensor.centralite_3210_l_ac_frequency", + "sensor.centralite_3210_l_power_factor", + "sensor.centralite_3210_l_instantaneous_demand", + "sensor.centralite_3210_l_summation_delivered", "switch.centralite_3210_l_switch", "sensor.centralite_3210_l_rssi", "sensor.centralite_3210_l_lqi", @@ -198,47 +198,47 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_3210_l_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3210_l_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -268,7 +268,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_3310_s_identifybutton", + "button.centralite_3310_s_identify", "sensor.centralite_3310_s_battery", "sensor.centralite_3310_s_temperature", "sensor.centralite_3310_s_humidity", @@ -279,7 +279,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_3310_s_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3310_s_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -331,7 +331,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_3315_s_identifybutton", + "button.centralite_3315_s_identify", "sensor.centralite_3315_s_battery", "sensor.centralite_3315_s_temperature", "binary_sensor.centralite_3315_s_iaszone", @@ -347,7 +347,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_3315_s_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3315_s_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -394,7 +394,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_3320_l_identifybutton", + "button.centralite_3320_l_identify", "sensor.centralite_3320_l_battery", "sensor.centralite_3320_l_temperature", "binary_sensor.centralite_3320_l_iaszone", @@ -410,7 +410,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_3320_l_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3320_l_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -457,7 +457,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_3326_l_identifybutton", + "button.centralite_3326_l_identify", "sensor.centralite_3326_l_battery", "sensor.centralite_3326_l_temperature", "binary_sensor.centralite_3326_l_iaszone", @@ -473,7 +473,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_3326_l_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3326_l_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -520,7 +520,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.centralite_motion_sensor_a_identifybutton", + "button.centralite_motion_sensor_a_identify", "sensor.centralite_motion_sensor_a_battery", "sensor.centralite_motion_sensor_a_temperature", "binary_sensor.centralite_motion_sensor_a_iaszone", @@ -537,7 +537,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.centralite_motion_sensor_a_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.centralite_motion_sensor_a_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -589,9 +589,9 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["4:0x0019"], DEV_SIG_ENTITIES: [ - "button.climaxtechnology_psmp5_00_00_02_02tc_identifybutton", - "sensor.climaxtechnology_psmp5_00_00_02_02tc_smartenergymetering", - "sensor.climaxtechnology_psmp5_00_00_02_02tc_smartenergysummation", + "button.climaxtechnology_psmp5_00_00_02_02tc_identify", + "sensor.climaxtechnology_psmp5_00_00_02_02tc_instantaneous_demand", + "sensor.climaxtechnology_psmp5_00_00_02_02tc_summation_delivered", "switch.climaxtechnology_psmp5_00_00_02_02tc_switch", "sensor.climaxtechnology_psmp5_00_00_02_02tc_rssi", "sensor.climaxtechnology_psmp5_00_00_02_02tc_lqi", @@ -605,17 +605,17 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_psmp5_00_00_02_02tc_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_psmp5_00_00_02_02tc_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -645,14 +645,14 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.climaxtechnology_sd8sc_00_00_03_12tc_identifybutton", + "button.climaxtechnology_sd8sc_00_00_03_12tc_identify", "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_iaszone", "sensor.climaxtechnology_sd8sc_00_00_03_12tc_rssi", "sensor.climaxtechnology_sd8sc_00_00_03_12tc_lqi", - "select.climaxtechnology_sd8sc_00_00_03_12tc_defaulttoneselect", - "select.climaxtechnology_sd8sc_00_00_03_12tc_defaultsirenlevelselect", - "select.climaxtechnology_sd8sc_00_00_03_12tc_defaultstrobelevelselect", - "select.climaxtechnology_sd8sc_00_00_03_12tc_defaultstrobeselect", + "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_tone", + "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_level", + "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe_level", + "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe", "siren.climaxtechnology_sd8sc_00_00_03_12tc_siren", ], DEV_SIG_ENT_MAP: { @@ -664,7 +664,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_sd8sc_00_00_03_12tc_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_sd8sc_00_00_03_12tc_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -679,22 +679,22 @@ DEVICES = [ ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_defaulttoneselect", + DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_tone", }, ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_defaultsirenlevelselect", + DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_defaultstrobelevelselect", + DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_defaultstrobeselect", + DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe", }, ("siren", "00:11:22:33:44:55:66:77-1-1282"): { DEV_SIG_CHANNELS: ["ias_wd"], @@ -719,7 +719,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.climaxtechnology_ws15_00_00_03_03tc_identifybutton", + "button.climaxtechnology_ws15_00_00_03_03tc_identify", "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_iaszone", "sensor.climaxtechnology_ws15_00_00_03_03tc_rssi", "sensor.climaxtechnology_ws15_00_00_03_03tc_lqi", @@ -733,7 +733,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_ws15_00_00_03_03tc_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_ws15_00_00_03_03tc_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -770,7 +770,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.feibit_inc_co_fb56_zcw08ku1_1_identifybutton", + "button.feibit_inc_co_fb56_zcw08ku1_1_identify", "light.feibit_inc_co_fb56_zcw08ku1_1_light", "sensor.feibit_inc_co_fb56_zcw08ku1_1_rssi", "sensor.feibit_inc_co_fb56_zcw08ku1_1_lqi", @@ -784,7 +784,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-11-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.feibit_inc_co_fb56_zcw08ku1_1_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.feibit_inc_co_fb56_zcw08ku1_1_identify", }, ("sensor", "00:11:22:33:44:55:66:77-11-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -814,15 +814,15 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.heiman_smokesensor_em_identifybutton", + "button.heiman_smokesensor_em_identify", "sensor.heiman_smokesensor_em_battery", "binary_sensor.heiman_smokesensor_em_iaszone", "sensor.heiman_smokesensor_em_rssi", "sensor.heiman_smokesensor_em_lqi", - "select.heiman_smokesensor_em_defaulttoneselect", - "select.heiman_smokesensor_em_defaultsirenlevelselect", - "select.heiman_smokesensor_em_defaultstrobelevelselect", - "select.heiman_smokesensor_em_defaultstrobeselect", + "select.heiman_smokesensor_em_default_siren_tone", + "select.heiman_smokesensor_em_default_siren_level", + "select.heiman_smokesensor_em_default_strobe_level", + "select.heiman_smokesensor_em_default_strobe", "siren.heiman_smokesensor_em_siren", ], DEV_SIG_ENT_MAP: { @@ -834,7 +834,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.heiman_smokesensor_em_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.heiman_smokesensor_em_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -854,22 +854,22 @@ DEVICES = [ ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_defaulttoneselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_siren_tone", }, ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_defaultsirenlevelselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_siren_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_defaultstrobelevelselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_strobe_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_defaultstrobeselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_strobe", }, ("siren", "00:11:22:33:44:55:66:77-1-1282"): { DEV_SIG_CHANNELS: ["ias_wd"], @@ -894,7 +894,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.heiman_co_v16_identifybutton", + "button.heiman_co_v16_identify", "binary_sensor.heiman_co_v16_iaszone", "sensor.heiman_co_v16_rssi", "sensor.heiman_co_v16_lqi", @@ -908,7 +908,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.heiman_co_v16_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.heiman_co_v16_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -938,36 +938,36 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.heiman_warningdevice_identifybutton", + "button.heiman_warningdevice_identify", "binary_sensor.heiman_warningdevice_iaszone", "sensor.heiman_warningdevice_rssi", "sensor.heiman_warningdevice_lqi", - "select.heiman_warningdevice_defaulttoneselect", - "select.heiman_warningdevice_defaultsirenlevelselect", - "select.heiman_warningdevice_defaultstrobelevelselect", - "select.heiman_warningdevice_defaultstrobeselect", + "select.heiman_warningdevice_default_siren_tone", + "select.heiman_warningdevice_default_siren_level", + "select.heiman_warningdevice_default_strobe_level", + "select.heiman_warningdevice_default_strobe", "siren.heiman_warningdevice_siren", ], DEV_SIG_ENT_MAP: { ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_defaulttoneselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_siren_tone", }, ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_defaultsirenlevelselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_siren_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_defaultstrobelevelselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_strobe_level", }, ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_defaultstrobeselect", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_strobe", }, ("siren", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["ias_wd"], @@ -982,7 +982,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.heiman_warningdevice_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.heiman_warningdevice_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1012,7 +1012,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["6:0x0019"], DEV_SIG_ENTITIES: [ - "button.hivehome_com_mot003_identifybutton", + "button.hivehome_com_mot003_identify", "sensor.hivehome_com_mot003_battery", "sensor.hivehome_com_mot003_illuminance", "sensor.hivehome_com_mot003_temperature", @@ -1029,7 +1029,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-6-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.hivehome_com_mot003_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.hivehome_com_mot003_identify", }, ("sensor", "00:11:22:33:44:55:66:77-6-1"): { DEV_SIG_CHANNELS: ["power"], @@ -1081,7 +1081,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_identifybutton", + "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_identify", "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_light", "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_rssi", "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_lqi", @@ -1095,7 +1095,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1125,7 +1125,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_identifybutton", + "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_identify", "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_light", "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_rssi", "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_lqi", @@ -1139,7 +1139,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1169,7 +1169,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_identifybutton", + "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_identify", "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_light", "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_rssi", "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_lqi", @@ -1183,7 +1183,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1213,7 +1213,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_identifybutton", + "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_identify", "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_light", "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_rssi", "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_lqi", @@ -1227,7 +1227,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1257,7 +1257,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_identifybutton", + "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_identify", "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_light", "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_rssi", "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_lqi", @@ -1271,7 +1271,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1301,7 +1301,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_control_outlet_identifybutton", + "button.ikea_of_sweden_tradfri_control_outlet_identify", "switch.ikea_of_sweden_tradfri_control_outlet_switch", "sensor.ikea_of_sweden_tradfri_control_outlet_rssi", "sensor.ikea_of_sweden_tradfri_control_outlet_lqi", @@ -1315,7 +1315,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_control_outlet_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_control_outlet_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1345,7 +1345,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_motion_sensor_identifybutton", + "button.ikea_of_sweden_tradfri_motion_sensor_identify", "sensor.ikea_of_sweden_tradfri_motion_sensor_battery", "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_motion", "sensor.ikea_of_sweden_tradfri_motion_sensor_rssi", @@ -1355,7 +1355,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_motion_sensor_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_motion_sensor_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -1395,7 +1395,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0102"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_on_off_switch_identifybutton", + "button.ikea_of_sweden_tradfri_on_off_switch_identify", "sensor.ikea_of_sweden_tradfri_on_off_switch_battery", "sensor.ikea_of_sweden_tradfri_on_off_switch_rssi", "sensor.ikea_of_sweden_tradfri_on_off_switch_lqi", @@ -1404,7 +1404,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_on_off_switch_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_on_off_switch_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -1439,7 +1439,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_remote_control_identifybutton", + "button.ikea_of_sweden_tradfri_remote_control_identify", "sensor.ikea_of_sweden_tradfri_remote_control_battery", "sensor.ikea_of_sweden_tradfri_remote_control_rssi", "sensor.ikea_of_sweden_tradfri_remote_control_lqi", @@ -1448,7 +1448,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_remote_control_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_remote_control_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -1490,7 +1490,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_signal_repeater_identifybutton", + "button.ikea_of_sweden_tradfri_signal_repeater_identify", "sensor.ikea_of_sweden_tradfri_signal_repeater_rssi", "sensor.ikea_of_sweden_tradfri_signal_repeater_lqi", ], @@ -1498,7 +1498,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_signal_repeater_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_signal_repeater_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1528,7 +1528,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ikea_of_sweden_tradfri_wireless_dimmer_identifybutton", + "button.ikea_of_sweden_tradfri_wireless_dimmer_identify", "sensor.ikea_of_sweden_tradfri_wireless_dimmer_battery", "sensor.ikea_of_sweden_tradfri_wireless_dimmer_rssi", "sensor.ikea_of_sweden_tradfri_wireless_dimmer_lqi", @@ -1537,7 +1537,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_wireless_dimmer_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_wireless_dimmer_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -1579,9 +1579,9 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], DEV_SIG_ENTITIES: [ - "button.jasco_products_45852_identifybutton", - "sensor.jasco_products_45852_smartenergymetering", - "sensor.jasco_products_45852_smartenergysummation", + "button.jasco_products_45852_identify", + "sensor.jasco_products_45852_instantaneous_demand", + "sensor.jasco_products_45852_summation_delivered", "light.jasco_products_45852_light", "sensor.jasco_products_45852_rssi", "sensor.jasco_products_45852_lqi", @@ -1595,17 +1595,17 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.jasco_products_45852_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.jasco_products_45852_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1642,10 +1642,10 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], DEV_SIG_ENTITIES: [ - "button.jasco_products_45856_identifybutton", + "button.jasco_products_45856_identify", "light.jasco_products_45856_light", - "sensor.jasco_products_45856_smartenergymetering", - "sensor.jasco_products_45856_smartenergysummation", + "sensor.jasco_products_45856_instantaneous_demand", + "sensor.jasco_products_45856_summation_delivered", "sensor.jasco_products_45856_rssi", "sensor.jasco_products_45856_lqi", ], @@ -1658,17 +1658,17 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.jasco_products_45856_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.jasco_products_45856_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1705,10 +1705,10 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], DEV_SIG_ENTITIES: [ - "button.jasco_products_45857_identifybutton", + "button.jasco_products_45857_identify", "light.jasco_products_45857_light", - "sensor.jasco_products_45857_smartenergymetering", - "sensor.jasco_products_45857_smartenergysummation", + "sensor.jasco_products_45857_instantaneous_demand", + "sensor.jasco_products_45857_summation_delivered", "sensor.jasco_products_45857_rssi", "sensor.jasco_products_45857_lqi", ], @@ -1721,17 +1721,17 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.jasco_products_45857_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.jasco_products_45857_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1761,7 +1761,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.keen_home_inc_sv02_610_mp_1_3_identifybutton", + "button.keen_home_inc_sv02_610_mp_1_3_identify", "sensor.keen_home_inc_sv02_610_mp_1_3_battery", "sensor.keen_home_inc_sv02_610_mp_1_3_pressure", "sensor.keen_home_inc_sv02_610_mp_1_3_temperature", @@ -1773,7 +1773,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_610_mp_1_3_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_610_mp_1_3_identify", }, ("cover", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["level", "on_off"], @@ -1823,7 +1823,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.keen_home_inc_sv02_612_mp_1_2_identifybutton", + "button.keen_home_inc_sv02_612_mp_1_2_identify", "sensor.keen_home_inc_sv02_612_mp_1_2_battery", "sensor.keen_home_inc_sv02_612_mp_1_2_pressure", "sensor.keen_home_inc_sv02_612_mp_1_2_temperature", @@ -1835,7 +1835,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_2_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_2_identify", }, ("cover", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["level", "on_off"], @@ -1885,7 +1885,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.keen_home_inc_sv02_612_mp_1_3_identifybutton", + "button.keen_home_inc_sv02_612_mp_1_3_identify", "sensor.keen_home_inc_sv02_612_mp_1_3_battery", "sensor.keen_home_inc_sv02_612_mp_1_3_pressure", "sensor.keen_home_inc_sv02_612_mp_1_3_temperature", @@ -1897,7 +1897,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_3_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_3_identify", }, ("cover", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["level", "on_off"], @@ -1947,7 +1947,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.king_of_fans_inc_hbuniversalcfremote_identifybutton", + "button.king_of_fans_inc_hbuniversalcfremote_identify", "light.king_of_fans_inc_hbuniversalcfremote_light", "fan.king_of_fans_inc_hbuniversalcfremote_fan", "sensor.king_of_fans_inc_hbuniversalcfremote_rssi", @@ -1962,7 +1962,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.king_of_fans_inc_hbuniversalcfremote_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.king_of_fans_inc_hbuniversalcfremote_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -1997,7 +1997,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0300"], DEV_SIG_ENTITIES: [ - "button.lds_zbt_cctswitch_d0001_identifybutton", + "button.lds_zbt_cctswitch_d0001_identify", "sensor.lds_zbt_cctswitch_d0001_battery", "sensor.lds_zbt_cctswitch_d0001_rssi", "sensor.lds_zbt_cctswitch_d0001_lqi", @@ -2006,7 +2006,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lds_zbt_cctswitch_d0001_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lds_zbt_cctswitch_d0001_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -2041,7 +2041,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ledvance_a19_rgbw_identifybutton", + "button.ledvance_a19_rgbw_identify", "light.ledvance_a19_rgbw_light", "sensor.ledvance_a19_rgbw_rssi", "sensor.ledvance_a19_rgbw_lqi", @@ -2055,7 +2055,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ledvance_a19_rgbw_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_a19_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2085,7 +2085,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ledvance_flex_rgbw_identifybutton", + "button.ledvance_flex_rgbw_identify", "light.ledvance_flex_rgbw_light", "sensor.ledvance_flex_rgbw_rssi", "sensor.ledvance_flex_rgbw_lqi", @@ -2099,7 +2099,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ledvance_flex_rgbw_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_flex_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2129,7 +2129,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ledvance_plug_identifybutton", + "button.ledvance_plug_identify", "switch.ledvance_plug_switch", "sensor.ledvance_plug_rssi", "sensor.ledvance_plug_lqi", @@ -2143,7 +2143,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ledvance_plug_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_plug_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2173,7 +2173,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.ledvance_rt_rgbw_identifybutton", + "button.ledvance_rt_rgbw_identify", "light.ledvance_rt_rgbw_light", "sensor.ledvance_rt_rgbw_rssi", "sensor.ledvance_rt_rgbw_lqi", @@ -2187,7 +2187,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.ledvance_rt_rgbw_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_rt_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2238,20 +2238,20 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_plug_maus01_identifybutton", - "sensor.lumi_lumi_plug_maus01_electricalmeasurement", - "sensor.lumi_lumi_plug_maus01_electricalmeasurementapparentpower", - "sensor.lumi_lumi_plug_maus01_electricalmeasurementrmscurrent", - "sensor.lumi_lumi_plug_maus01_electricalmeasurementrmsvoltage", - "sensor.lumi_lumi_plug_maus01_electricalmeasurementfrequency", - "sensor.lumi_lumi_plug_maus01_electricalmeasurementpowerfactor", + "button.lumi_lumi_plug_maus01_identify", + "sensor.lumi_lumi_plug_maus01_active_power", + "sensor.lumi_lumi_plug_maus01_apparent_power", + "sensor.lumi_lumi_plug_maus01_rms_current", + "sensor.lumi_lumi_plug_maus01_rms_voltage", + "sensor.lumi_lumi_plug_maus01_ac_frequency", + "sensor.lumi_lumi_plug_maus01_power_factor", "sensor.lumi_lumi_plug_maus01_analoginput", "sensor.lumi_lumi_plug_maus01_analoginput_2", "binary_sensor.lumi_lumi_plug_maus01_binaryinput", "switch.lumi_lumi_plug_maus01_switch", "sensor.lumi_lumi_plug_maus01_rssi", "sensor.lumi_lumi_plug_maus01_lqi", - "sensor.lumi_lumi_plug_maus01_devicetemperature", + "sensor.lumi_lumi_plug_maus01_device_temperature", ], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { @@ -2262,42 +2262,42 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2"): { DEV_SIG_CHANNELS: ["device_temperature"], DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_devicetemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_device_temperature", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_plug_maus01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_plug_maus01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2349,18 +2349,18 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_relay_c2acn01_identifybutton", + "button.lumi_lumi_relay_c2acn01_identify", "light.lumi_lumi_relay_c2acn01_light", "light.lumi_lumi_relay_c2acn01_light_2", - "sensor.lumi_lumi_relay_c2acn01_electricalmeasurement", - "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementapparentpower", - "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementrmscurrent", - "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementrmsvoltage", - "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementfrequency", - "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementpowerfactor", + "sensor.lumi_lumi_relay_c2acn01_active_power", + "sensor.lumi_lumi_relay_c2acn01_apparent_power", + "sensor.lumi_lumi_relay_c2acn01_rms_current", + "sensor.lumi_lumi_relay_c2acn01_rms_voltage", + "sensor.lumi_lumi_relay_c2acn01_ac_frequency", + "sensor.lumi_lumi_relay_c2acn01_power_factor", "sensor.lumi_lumi_relay_c2acn01_rssi", "sensor.lumi_lumi_relay_c2acn01_lqi", - "sensor.lumi_lumi_relay_c2acn01_devicetemperature", + "sensor.lumi_lumi_relay_c2acn01_device_temperature", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -2371,42 +2371,42 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2"): { DEV_SIG_CHANNELS: ["device_temperature"], DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_devicetemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_device_temperature", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_relay_c2acn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_relay_c2acn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2455,7 +2455,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b186acn01_identifybutton", + "button.lumi_lumi_remote_b186acn01_identify", "sensor.lumi_lumi_remote_b186acn01_battery", "sensor.lumi_lumi_remote_b186acn01_rssi", "sensor.lumi_lumi_remote_b186acn01_lqi", @@ -2464,7 +2464,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b186acn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b186acn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -2513,7 +2513,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b286acn01_identifybutton", + "button.lumi_lumi_remote_b286acn01_identify", "sensor.lumi_lumi_remote_b286acn01_battery", "sensor.lumi_lumi_remote_b286acn01_rssi", "sensor.lumi_lumi_remote_b286acn01_lqi", @@ -2522,7 +2522,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286acn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286acn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -2592,7 +2592,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b286opcn01_identifybutton", + "button.lumi_lumi_remote_b286opcn01_identify", "sensor.lumi_lumi_remote_b286opcn01_rssi", "sensor.lumi_lumi_remote_b286opcn01_lqi", ], @@ -2600,7 +2600,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286opcn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286opcn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2665,7 +2665,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b486opcn01_identifybutton", + "button.lumi_lumi_remote_b486opcn01_identify", "sensor.lumi_lumi_remote_b486opcn01_rssi", "sensor.lumi_lumi_remote_b486opcn01_lqi", ], @@ -2673,7 +2673,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b486opcn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b486opcn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2703,7 +2703,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b686opcn01_identifybutton", + "button.lumi_lumi_remote_b686opcn01_identify", "sensor.lumi_lumi_remote_b686opcn01_rssi", "sensor.lumi_lumi_remote_b686opcn01_lqi", ], @@ -2711,7 +2711,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2776,7 +2776,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_remote_b686opcn01_identifybutton", + "button.lumi_lumi_remote_b686opcn01_identify", "sensor.lumi_lumi_remote_b686opcn01_rssi", "sensor.lumi_lumi_remote_b686opcn01_lqi", ], @@ -2784,7 +2784,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -2946,7 +2946,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sen_ill_mgl01_identifybutton", + "button.lumi_lumi_sen_ill_mgl01_identify", "sensor.lumi_lumi_sen_ill_mgl01_illuminance", "sensor.lumi_lumi_sen_ill_mgl01_rssi", "sensor.lumi_lumi_sen_ill_mgl01_lqi", @@ -2955,7 +2955,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sen_ill_mgl01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sen_ill_mgl01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { DEV_SIG_CHANNELS: ["illuminance"], @@ -3004,7 +3004,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_86sw1_identifybutton", + "button.lumi_lumi_sensor_86sw1_identify", "sensor.lumi_lumi_sensor_86sw1_battery", "sensor.lumi_lumi_sensor_86sw1_rssi", "sensor.lumi_lumi_sensor_86sw1_lqi", @@ -3013,7 +3013,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_86sw1_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_86sw1_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3062,7 +3062,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_cube_aqgl01_identifybutton", + "button.lumi_lumi_sensor_cube_aqgl01_identify", "sensor.lumi_lumi_sensor_cube_aqgl01_battery", "sensor.lumi_lumi_sensor_cube_aqgl01_rssi", "sensor.lumi_lumi_sensor_cube_aqgl01_lqi", @@ -3071,7 +3071,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_cube_aqgl01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_cube_aqgl01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3120,7 +3120,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_ht_identifybutton", + "button.lumi_lumi_sensor_ht_identify", "sensor.lumi_lumi_sensor_ht_battery", "sensor.lumi_lumi_sensor_ht_temperature", "sensor.lumi_lumi_sensor_ht_humidity", @@ -3131,7 +3131,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_ht_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_ht_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3176,7 +3176,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_magnet_identifybutton", + "button.lumi_lumi_sensor_magnet_identify", "sensor.lumi_lumi_sensor_magnet_battery", "binary_sensor.lumi_lumi_sensor_magnet_opening", "sensor.lumi_lumi_sensor_magnet_rssi", @@ -3186,7 +3186,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3226,7 +3226,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_magnet_aq2_identifybutton", + "button.lumi_lumi_sensor_magnet_aq2_identify", "sensor.lumi_lumi_sensor_magnet_aq2_battery", "binary_sensor.lumi_lumi_sensor_magnet_aq2_opening", "sensor.lumi_lumi_sensor_magnet_aq2_rssi", @@ -3236,7 +3236,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_aq2_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_aq2_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3276,7 +3276,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_motion_aq2_identifybutton", + "button.lumi_lumi_sensor_motion_aq2_identify", "sensor.lumi_lumi_sensor_motion_aq2_battery", "sensor.lumi_lumi_sensor_motion_aq2_illuminance", "binary_sensor.lumi_lumi_sensor_motion_aq2_occupancy", @@ -3298,7 +3298,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_motion_aq2_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_motion_aq2_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3338,7 +3338,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_smoke_identifybutton", + "button.lumi_lumi_sensor_smoke_identify", "sensor.lumi_lumi_sensor_smoke_battery", "binary_sensor.lumi_lumi_sensor_smoke_iaszone", "sensor.lumi_lumi_sensor_smoke_rssi", @@ -3353,7 +3353,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_smoke_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_smoke_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3388,7 +3388,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_switch_identifybutton", + "button.lumi_lumi_sensor_switch_identify", "sensor.lumi_lumi_sensor_switch_battery", "sensor.lumi_lumi_sensor_switch_rssi", "sensor.lumi_lumi_sensor_switch_lqi", @@ -3397,7 +3397,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_switch_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_switch_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3508,12 +3508,12 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_sensor_wleak_aq1_identifybutton", + "button.lumi_lumi_sensor_wleak_aq1_identify", "sensor.lumi_lumi_sensor_wleak_aq1_battery", "binary_sensor.lumi_lumi_sensor_wleak_aq1_iaszone", "sensor.lumi_lumi_sensor_wleak_aq1_rssi", "sensor.lumi_lumi_sensor_wleak_aq1_lqi", - "sensor.lumi_lumi_sensor_wleak_aq1_devicetemperature", + "sensor.lumi_lumi_sensor_wleak_aq1_device_temperature", ], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { @@ -3524,12 +3524,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2"): { DEV_SIG_CHANNELS: ["device_temperature"], DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_devicetemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_device_temperature", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_wleak_aq1_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_wleak_aq1_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3571,7 +3571,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005"], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_vibration_aq1_identifybutton", + "button.lumi_lumi_vibration_aq1_identify", "sensor.lumi_lumi_vibration_aq1_battery", "binary_sensor.lumi_lumi_vibration_aq1_iaszone", "lock.lumi_lumi_vibration_aq1_doorlock", @@ -3587,7 +3587,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_vibration_aq1_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_vibration_aq1_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3627,7 +3627,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.lumi_lumi_weather_identifybutton", + "button.lumi_lumi_weather_identify", "sensor.lumi_lumi_weather_battery", "sensor.lumi_lumi_weather_pressure", "sensor.lumi_lumi_weather_temperature", @@ -3639,7 +3639,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_weather_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_weather_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3689,7 +3689,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.nyce_3010_identifybutton", + "button.nyce_3010_identify", "sensor.nyce_3010_battery", "binary_sensor.nyce_3010_iaszone", "sensor.nyce_3010_rssi", @@ -3704,7 +3704,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.nyce_3010_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.nyce_3010_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3739,7 +3739,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.nyce_3014_identifybutton", + "button.nyce_3014_identify", "sensor.nyce_3014_battery", "binary_sensor.nyce_3014_iaszone", "sensor.nyce_3014_rssi", @@ -3754,7 +3754,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.nyce_3014_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.nyce_3014_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3832,7 +3832,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["3:0x0019"], DEV_SIG_ENTITIES: [ - "button.osram_lightify_a19_rgbw_identifybutton", + "button.osram_lightify_a19_rgbw_identify", "light.osram_lightify_a19_rgbw_light", "sensor.osram_lightify_a19_rgbw_rssi", "sensor.osram_lightify_a19_rgbw_lqi", @@ -3846,7 +3846,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-3-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.osram_lightify_a19_rgbw_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_a19_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -3876,7 +3876,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.osram_lightify_dimming_switch_identifybutton", + "button.osram_lightify_dimming_switch_identify", "sensor.osram_lightify_dimming_switch_battery", "sensor.osram_lightify_dimming_switch_rssi", "sensor.osram_lightify_dimming_switch_lqi", @@ -3885,7 +3885,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.osram_lightify_dimming_switch_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_dimming_switch_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3920,7 +3920,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["3:0x0019"], DEV_SIG_ENTITIES: [ - "button.osram_lightify_flex_rgbw_identifybutton", + "button.osram_lightify_flex_rgbw_identify", "light.osram_lightify_flex_rgbw_light", "sensor.osram_lightify_flex_rgbw_rssi", "sensor.osram_lightify_flex_rgbw_lqi", @@ -3934,7 +3934,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-3-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.osram_lightify_flex_rgbw_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_flex_rgbw_identify", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -3964,14 +3964,14 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["3:0x0019"], DEV_SIG_ENTITIES: [ - "button.osram_lightify_rt_tunable_white_identifybutton", + "button.osram_lightify_rt_tunable_white_identify", "light.osram_lightify_rt_tunable_white_light", - "sensor.osram_lightify_rt_tunable_white_electricalmeasurement", - "sensor.osram_lightify_rt_tunable_white_electricalmeasurementapparentpower", - "sensor.osram_lightify_rt_tunable_white_electricalmeasurementrmscurrent", - "sensor.osram_lightify_rt_tunable_white_electricalmeasurementrmsvoltage", - "sensor.osram_lightify_rt_tunable_white_electricalmeasurementfrequency", - "sensor.osram_lightify_rt_tunable_white_electricalmeasurementpowerfactor", + "sensor.osram_lightify_rt_tunable_white_active_power", + "sensor.osram_lightify_rt_tunable_white_apparent_power", + "sensor.osram_lightify_rt_tunable_white_rms_current", + "sensor.osram_lightify_rt_tunable_white_rms_voltage", + "sensor.osram_lightify_rt_tunable_white_ac_frequency", + "sensor.osram_lightify_rt_tunable_white_power_factor", "sensor.osram_lightify_rt_tunable_white_rssi", "sensor.osram_lightify_rt_tunable_white_lqi", ], @@ -3984,37 +3984,37 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-3-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.osram_lightify_rt_tunable_white_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_rt_tunable_white_identify", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -4044,13 +4044,13 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["3:0x0019"], DEV_SIG_ENTITIES: [ - "button.osram_plug_01_identifybutton", - "sensor.osram_plug_01_electricalmeasurement", - "sensor.osram_plug_01_electricalmeasurementapparentpower", - "sensor.osram_plug_01_electricalmeasurementrmscurrent", - "sensor.osram_plug_01_electricalmeasurementrmsvoltage", - "sensor.osram_plug_01_electricalmeasurementfrequency", - "sensor.osram_plug_01_electricalmeasurementpowerfactor", + "button.osram_plug_01_identify", + "sensor.osram_plug_01_active_power", + "sensor.osram_plug_01_apparent_power", + "sensor.osram_plug_01_rms_current", + "sensor.osram_plug_01_rms_voltage", + "sensor.osram_plug_01_ac_frequency", + "sensor.osram_plug_01_power_factor", "switch.osram_plug_01_switch", "sensor.osram_plug_01_rssi", "sensor.osram_plug_01_lqi", @@ -4064,37 +4064,37 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-3-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.osram_plug_01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.osram_plug_01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -4230,7 +4230,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"], DEV_SIG_ENTITIES: [ - "button.philips_rwl020_identifybutton", + "button.philips_rwl020_identify", "sensor.philips_rwl020_battery", "binary_sensor.philips_rwl020_binaryinput", "sensor.philips_rwl020_rssi", @@ -4255,7 +4255,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-2-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.philips_rwl020_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.philips_rwl020_identify", }, ("sensor", "00:11:22:33:44:55:66:77-2-1"): { DEV_SIG_CHANNELS: ["power"], @@ -4280,7 +4280,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.samjin_button_identifybutton", + "button.samjin_button_identify", "sensor.samjin_button_battery", "sensor.samjin_button_temperature", "binary_sensor.samjin_button_iaszone", @@ -4296,7 +4296,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.samjin_button_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.samjin_button_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -4336,7 +4336,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.samjin_multi_identifybutton", + "button.samjin_multi_identify", "sensor.samjin_multi_battery", "sensor.samjin_multi_temperature", "binary_sensor.samjin_multi_iaszone", @@ -4352,7 +4352,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.samjin_multi_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.samjin_multi_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -4392,7 +4392,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.samjin_water_identifybutton", + "button.samjin_water_identify", "sensor.samjin_water_battery", "sensor.samjin_water_temperature", "binary_sensor.samjin_water_iaszone", @@ -4408,7 +4408,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.samjin_water_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.samjin_water_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -4448,13 +4448,13 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0019"], DEV_SIG_ENTITIES: [ - "button.securifi_ltd_unk_model_identifybutton", - "sensor.securifi_ltd_unk_model_electricalmeasurement", - "sensor.securifi_ltd_unk_model_electricalmeasurementapparentpower", - "sensor.securifi_ltd_unk_model_electricalmeasurementrmscurrent", - "sensor.securifi_ltd_unk_model_electricalmeasurementrmsvoltage", - "sensor.securifi_ltd_unk_model_electricalmeasurementfrequency", - "sensor.securifi_ltd_unk_model_electricalmeasurementpowerfactor", + "button.securifi_ltd_unk_model_identify", + "sensor.securifi_ltd_unk_model_active_power", + "sensor.securifi_ltd_unk_model_apparent_power", + "sensor.securifi_ltd_unk_model_rms_current", + "sensor.securifi_ltd_unk_model_rms_voltage", + "sensor.securifi_ltd_unk_model_ac_frequency", + "sensor.securifi_ltd_unk_model_power_factor", "switch.securifi_ltd_unk_model_switch", "sensor.securifi_ltd_unk_model_rssi", "sensor.securifi_ltd_unk_model_lqi", @@ -4463,37 +4463,37 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.securifi_ltd_unk_model_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.securifi_ltd_unk_model_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -4528,7 +4528,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sercomm_corp_sz_dws04n_sf_identifybutton", + "button.sercomm_corp_sz_dws04n_sf_identify", "sensor.sercomm_corp_sz_dws04n_sf_battery", "sensor.sercomm_corp_sz_dws04n_sf_temperature", "binary_sensor.sercomm_corp_sz_dws04n_sf_iaszone", @@ -4544,7 +4544,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_dws04n_sf_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_dws04n_sf_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -4591,15 +4591,15 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], DEV_SIG_ENTITIES: [ - "button.sercomm_corp_sz_esw01_identifybutton", - "sensor.sercomm_corp_sz_esw01_electricalmeasurement", - "sensor.sercomm_corp_sz_esw01_electricalmeasurementapparentpower", - "sensor.sercomm_corp_sz_esw01_electricalmeasurementrmscurrent", - "sensor.sercomm_corp_sz_esw01_electricalmeasurementrmsvoltage", - "sensor.sercomm_corp_sz_esw01_electricalmeasurementfrequency", - "sensor.sercomm_corp_sz_esw01_electricalmeasurementpowerfactor", - "sensor.sercomm_corp_sz_esw01_smartenergymetering", - "sensor.sercomm_corp_sz_esw01_smartenergysummation", + "button.sercomm_corp_sz_esw01_identify", + "sensor.sercomm_corp_sz_esw01_active_power", + "sensor.sercomm_corp_sz_esw01_apparent_power", + "sensor.sercomm_corp_sz_esw01_rms_current", + "sensor.sercomm_corp_sz_esw01_rms_voltage", + "sensor.sercomm_corp_sz_esw01_ac_frequency", + "sensor.sercomm_corp_sz_esw01_power_factor", + "sensor.sercomm_corp_sz_esw01_instantaneous_demand", + "sensor.sercomm_corp_sz_esw01_summation_delivered", "light.sercomm_corp_sz_esw01_light", "sensor.sercomm_corp_sz_esw01_rssi", "sensor.sercomm_corp_sz_esw01_lqi", @@ -4613,47 +4613,47 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_esw01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_esw01_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -4683,7 +4683,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sercomm_corp_sz_pir04_identifybutton", + "button.sercomm_corp_sz_pir04_identify", "sensor.sercomm_corp_sz_pir04_battery", "sensor.sercomm_corp_sz_pir04_illuminance", "sensor.sercomm_corp_sz_pir04_temperature", @@ -4700,7 +4700,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_pir04_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_pir04_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -4745,13 +4745,13 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sinope_technologies_rm3250zb_identifybutton", - "sensor.sinope_technologies_rm3250zb_electricalmeasurement", - "sensor.sinope_technologies_rm3250zb_electricalmeasurementapparentpower", - "sensor.sinope_technologies_rm3250zb_electricalmeasurementrmscurrent", - "sensor.sinope_technologies_rm3250zb_electricalmeasurementrmsvoltage", - "sensor.sinope_technologies_rm3250zb_electricalmeasurementfrequency", - "sensor.sinope_technologies_rm3250zb_electricalmeasurementpowerfactor", + "button.sinope_technologies_rm3250zb_identify", + "sensor.sinope_technologies_rm3250zb_active_power", + "sensor.sinope_technologies_rm3250zb_apparent_power", + "sensor.sinope_technologies_rm3250zb_rms_current", + "sensor.sinope_technologies_rm3250zb_rms_voltage", + "sensor.sinope_technologies_rm3250zb_ac_frequency", + "sensor.sinope_technologies_rm3250zb_power_factor", "switch.sinope_technologies_rm3250zb_switch", "sensor.sinope_technologies_rm3250zb_rssi", "sensor.sinope_technologies_rm3250zb_lqi", @@ -4760,37 +4760,37 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_rm3250zb_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_rm3250zb_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -4832,15 +4832,15 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sinope_technologies_th1123zb_identifybutton", - "sensor.sinope_technologies_th1123zb_electricalmeasurement", - "sensor.sinope_technologies_th1123zb_electricalmeasurementapparentpower", - "sensor.sinope_technologies_th1123zb_electricalmeasurementrmscurrent", - "sensor.sinope_technologies_th1123zb_electricalmeasurementrmsvoltage", - "sensor.sinope_technologies_th1123zb_electricalmeasurementfrequency", - "sensor.sinope_technologies_th1123zb_electricalmeasurementpowerfactor", + "button.sinope_technologies_th1123zb_identify", + "sensor.sinope_technologies_th1123zb_active_power", + "sensor.sinope_technologies_th1123zb_apparent_power", + "sensor.sinope_technologies_th1123zb_rms_current", + "sensor.sinope_technologies_th1123zb_rms_voltage", + "sensor.sinope_technologies_th1123zb_ac_frequency", + "sensor.sinope_technologies_th1123zb_power_factor", "sensor.sinope_technologies_th1123zb_temperature", - "sensor.sinope_technologies_th1123zb_sinopehvacaction", + "sensor.sinope_technologies_th1123zb_hvac_action", "climate.sinope_technologies_th1123zb_thermostat", "sensor.sinope_technologies_th1123zb_rssi", "sensor.sinope_technologies_th1123zb_lqi", @@ -4849,7 +4849,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1123zb_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1123zb_identify", }, ("climate", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["thermostat"], @@ -4859,32 +4859,32 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { DEV_SIG_CHANNELS: ["temperature"], @@ -4904,7 +4904,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { DEV_SIG_CHANNELS: ["thermostat"], DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_sinopehvacaction", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_hvac_action", }, }, }, @@ -4931,15 +4931,15 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sinope_technologies_th1124zb_identifybutton", - "sensor.sinope_technologies_th1124zb_electricalmeasurement", - "sensor.sinope_technologies_th1124zb_electricalmeasurementapparentpower", - "sensor.sinope_technologies_th1124zb_electricalmeasurementrmscurrent", - "sensor.sinope_technologies_th1124zb_electricalmeasurementrmsvoltage", - "sensor.sinope_technologies_th1124zb_electricalmeasurementfrequency", - "sensor.sinope_technologies_th1124zb_electricalmeasurementpowerfactor", + "button.sinope_technologies_th1124zb_identify", + "sensor.sinope_technologies_th1124zb_active_power", + "sensor.sinope_technologies_th1124zb_apparent_power", + "sensor.sinope_technologies_th1124zb_rms_current", + "sensor.sinope_technologies_th1124zb_rms_voltage", + "sensor.sinope_technologies_th1124zb_ac_frequency", + "sensor.sinope_technologies_th1124zb_power_factor", "sensor.sinope_technologies_th1124zb_temperature", - "sensor.sinope_technologies_th1124zb_sinopehvacaction", + "sensor.sinope_technologies_th1124zb_hvac_action", "climate.sinope_technologies_th1124zb_thermostat", "sensor.sinope_technologies_th1124zb_rssi", "sensor.sinope_technologies_th1124zb_lqi", @@ -4948,7 +4948,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1124zb_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1124zb_identify", }, ("climate", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["thermostat"], @@ -4958,32 +4958,32 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { DEV_SIG_CHANNELS: ["temperature"], @@ -5003,7 +5003,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { DEV_SIG_CHANNELS: ["thermostat"], DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_sinopehvacaction", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_hvac_action", }, }, }, @@ -5023,13 +5023,13 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.smartthings_outletv4_identifybutton", - "sensor.smartthings_outletv4_electricalmeasurement", - "sensor.smartthings_outletv4_electricalmeasurementapparentpower", - "sensor.smartthings_outletv4_electricalmeasurementrmscurrent", - "sensor.smartthings_outletv4_electricalmeasurementrmsvoltage", - "sensor.smartthings_outletv4_electricalmeasurementfrequency", - "sensor.smartthings_outletv4_electricalmeasurementpowerfactor", + "button.smartthings_outletv4_identify", + "sensor.smartthings_outletv4_active_power", + "sensor.smartthings_outletv4_apparent_power", + "sensor.smartthings_outletv4_rms_current", + "sensor.smartthings_outletv4_rms_voltage", + "sensor.smartthings_outletv4_ac_frequency", + "sensor.smartthings_outletv4_power_factor", "binary_sensor.smartthings_outletv4_binaryinput", "switch.smartthings_outletv4_switch", "sensor.smartthings_outletv4_rssi", @@ -5044,37 +5044,37 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.smartthings_outletv4_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.smartthings_outletv4_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_electricalmeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_electricalmeasurementapparentpower", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_apparent_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_electricalmeasurementrmscurrent", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_rms_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_electricalmeasurementrmsvoltage", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_rms_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_electricalmeasurementfrequency", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_ac_frequency", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_electricalmeasurementpowerfactor", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_power_factor", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -5109,7 +5109,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.smartthings_tagv4_identifybutton", + "button.smartthings_tagv4_identify", "device_tracker.smartthings_tagv4_devicescanner", "binary_sensor.smartthings_tagv4_binaryinput", "sensor.smartthings_tagv4_rssi", @@ -5129,7 +5129,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.smartthings_tagv4_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.smartthings_tagv4_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -5159,7 +5159,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.third_reality_inc_3rss007z_identifybutton", + "button.third_reality_inc_3rss007z_identify", "switch.third_reality_inc_3rss007z_switch", "sensor.third_reality_inc_3rss007z_rssi", "sensor.third_reality_inc_3rss007z_lqi", @@ -5168,7 +5168,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss007z_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss007z_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -5203,7 +5203,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.third_reality_inc_3rss008z_identifybutton", + "button.third_reality_inc_3rss008z_identify", "sensor.third_reality_inc_3rss008z_battery", "switch.third_reality_inc_3rss008z_switch", "sensor.third_reality_inc_3rss008z_rssi", @@ -5213,7 +5213,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss008z_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss008z_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -5253,7 +5253,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.visonic_mct_340_e_identifybutton", + "button.visonic_mct_340_e_identify", "sensor.visonic_mct_340_e_battery", "sensor.visonic_mct_340_e_temperature", "binary_sensor.visonic_mct_340_e_iaszone", @@ -5269,7 +5269,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.visonic_mct_340_e_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.visonic_mct_340_e_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -5309,9 +5309,9 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.zen_within_zen_01_identifybutton", + "button.zen_within_zen_01_identify", "sensor.zen_within_zen_01_battery", - "sensor.zen_within_zen_01_thermostathvacaction", + "sensor.zen_within_zen_01_hvac_action", "climate.zen_within_zen_01_zenwithinthermostat", "sensor.zen_within_zen_01_rssi", "sensor.zen_within_zen_01_lqi", @@ -5320,7 +5320,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.zen_within_zen_01_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.zen_within_zen_01_identify", }, ("climate", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["thermostat", "fan"], @@ -5345,7 +5345,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { DEV_SIG_CHANNELS: ["thermostat"], DEV_SIG_ENT_MAP_CLASS: "ThermostatHVACAction", - DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_thermostathvacaction", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_hvac_action", }, }, }, @@ -5442,7 +5442,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.netvox_z308e3ed_identifybutton", + "button.netvox_z308e3ed_identify", "sensor.netvox_z308e3ed_battery", "binary_sensor.netvox_z308e3ed_iaszone", "sensor.netvox_z308e3ed_rssi", @@ -5457,7 +5457,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.netvox_z308e3ed_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.netvox_z308e3ed_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], @@ -5492,10 +5492,10 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sengled_e11_g13_identifybutton", + "button.sengled_e11_g13_identify", "light.sengled_e11_g13_mintransitionlight", - "sensor.sengled_e11_g13_smartenergymetering", - "sensor.sengled_e11_g13_smartenergysummation", + "sensor.sengled_e11_g13_instantaneous_demand", + "sensor.sengled_e11_g13_summation_delivered", "sensor.sengled_e11_g13_rssi", "sensor.sengled_e11_g13_lqi", ], @@ -5508,17 +5508,17 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sengled_e11_g13_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sengled_e11_g13_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -5548,10 +5548,10 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sengled_e12_n14_identifybutton", + "button.sengled_e12_n14_identify", "light.sengled_e12_n14_mintransitionlight", - "sensor.sengled_e12_n14_smartenergymetering", - "sensor.sengled_e12_n14_smartenergysummation", + "sensor.sengled_e12_n14_instantaneous_demand", + "sensor.sengled_e12_n14_summation_delivered", "sensor.sengled_e12_n14_rssi", "sensor.sengled_e12_n14_lqi", ], @@ -5564,17 +5564,17 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sengled_e12_n14_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sengled_e12_n14_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -5604,10 +5604,10 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "button.sengled_z01_a19nae26_identifybutton", + "button.sengled_z01_a19nae26_identify", "light.sengled_z01_a19nae26_mintransitionlight", - "sensor.sengled_z01_a19nae26_smartenergymetering", - "sensor.sengled_z01_a19nae26_smartenergysummation", + "sensor.sengled_z01_a19nae26_instantaneous_demand", + "sensor.sengled_z01_a19nae26_summation_delivered", "sensor.sengled_z01_a19nae26_rssi", "sensor.sengled_z01_a19nae26_lqi", ], @@ -5620,17 +5620,17 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.sengled_z01_a19nae26_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.sengled_z01_a19nae26_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_smartenergymetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_instantaneous_demand", }, ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_smartenergysummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_summation_delivered", }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CHANNELS: ["basic"], @@ -5660,7 +5660,7 @@ DEVICES = [ }, DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "button.unk_manufacturer_unk_model_identifybutton", + "button.unk_manufacturer_unk_model_identify", "cover.unk_manufacturer_unk_model_shade", "sensor.unk_manufacturer_unk_model_rssi", "sensor.unk_manufacturer_unk_model_lqi", @@ -5669,7 +5669,7 @@ DEVICES = [ ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CHANNELS: ["identify"], DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", - DEV_SIG_ENT_MAP_ID: "button.unk_manufacturer_unk_model_identifybutton", + DEV_SIG_ENT_MAP_ID: "button.unk_manufacturer_unk_model_identify", }, ("cover", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["level", "on_off", "shade"], @@ -5962,7 +5962,7 @@ DEVICES = [ DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ "sensor.efektalab_ru_efekta_pws_battery", - "sensor.efektalab_ru_efekta_pws_soilmoisture", + "sensor.efektalab_ru_efekta_pws_soil_moisture", "sensor.efektalab_ru_efekta_pws_temperature", "sensor.efektalab_ru_efekta_pws_rssi", "sensor.efektalab_ru_efekta_pws_lqi", @@ -5976,7 +5976,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-1032"): { DEV_SIG_CHANNELS: ["soil_moisture"], DEV_SIG_ENT_MAP_CLASS: "SoilMoisture", - DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_soilmoisture", + DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_soil_moisture", }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { DEV_SIG_CHANNELS: ["temperature"], diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index c2079564dcf5cca1e0396f865276dc6dc20b3139..49fbe96f1623aefdf18bc406c8100ff5292aa44d 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -1,4 +1,16 @@ """Provide common test tools for Z-Wave JS.""" +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from zwave_js_server.model.node.data_model import NodeDataType + +from homeassistant.components.zwave_js.helpers import ( + ZwaveValueMatcher, + value_matches_matcher, +) + AIR_TEMPERATURE_SENSOR = "sensor.multisensor_6_air_temperature" BATTERY_SENSOR = "sensor.multisensor_6_battery_level" TAMPER_SENSOR = "binary_sensor.multisensor_6_tampering_product_cover_removed" @@ -37,3 +49,16 @@ HUMIDIFIER_ADC_T3000_ENTITY = "humidifier.adc_t3000_humidifier" DEHUMIDIFIER_ADC_T3000_ENTITY = "humidifier.adc_t3000_dehumidifier" PROPERTY_ULTRAVIOLET = "Ultraviolet" + + +def replace_value_of_zwave_value( + node_data: NodeDataType, matchers: list[ZwaveValueMatcher], new_value: Any +) -> NodeDataType: + """Replace the value of a zwave value that matches the input matchers.""" + new_node_data = deepcopy(node_data) + for value_data in new_node_data["values"]: + for matcher in matchers: + if value_matches_matcher(matcher, value_data): + value_data["value"] = new_value + + return new_node_data diff --git a/tests/components/zwave_js/test_addon.py b/tests/components/zwave_js/test_addon.py new file mode 100644 index 0000000000000000000000000000000000000000..45f732c1aa260ad56b9ad8183dd67c737570ad1b --- /dev/null +++ b/tests/components/zwave_js/test_addon.py @@ -0,0 +1,30 @@ +"""Tests for Z-Wave JS addon module.""" +import pytest + +from homeassistant.components.zwave_js.addon import AddonError, get_addon_manager +from homeassistant.components.zwave_js.const import ( + CONF_ADDON_DEVICE, + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, +) + + +async def test_not_installed_raises_exception(hass, addon_not_installed): + """Test addon not installed raises exception.""" + addon_manager = get_addon_manager(hass) + + addon_config = { + CONF_ADDON_DEVICE: "/test", + CONF_ADDON_S0_LEGACY_KEY: "123", + CONF_ADDON_S2_ACCESS_CONTROL_KEY: "456", + CONF_ADDON_S2_AUTHENTICATED_KEY: "789", + CONF_ADDON_S2_UNAUTHENTICATED_KEY: "012", + } + + with pytest.raises(AddonError): + await addon_manager.async_configure_addon(addon_config) + + with pytest.raises(AddonError): + await addon_manager.async_update_addon() diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 2a1c13b0db27692de59c697d186d2329b094ca5a..3d4971e1ce45fb20a8fa117d9cb52c5988ddeae7 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -3,7 +3,7 @@ from zwave_js_server.event import Event from zwave_js_server.model.node import Node from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityCategory @@ -69,6 +69,29 @@ async def test_enabled_legacy_sensor(hass, ecolink_door_sensor, integration): state = hass.states.get(ENABLED_LEGACY_BINARY_SENSOR) assert state.state == STATE_ON + # Test state updates from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 53, + "args": { + "commandClassName": "Binary Sensor", + "commandClass": 48, + "endpoint": 0, + "property": "Any", + "newValue": None, + "prevValue": True, + "propertyName": "Any", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(ENABLED_LEGACY_BINARY_SENSOR) + assert state.state == STATE_UNKNOWN + async def test_disabled_legacy_sensor(hass, multisensor_6, integration): """Test disabled legacy boolean binary sensor.""" @@ -198,3 +221,26 @@ async def test_property_sensor_door_status(hass, lock_august_pro, integration): state = hass.states.get(PROPERTY_DOOR_STATUS_BINARY_SENSOR) assert state assert state.state == STATE_OFF + + # door state unknown + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 6, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "doorStatus", + "newValue": None, + "prevValue": "open", + "propertyName": "doorStatus", + }, + }, + ) + node.receive_event(event) + state = hass.states.get(PROPERTY_DOOR_STATUS_BINARY_SENSOR) + assert state + assert state.state == STATE_UNKNOWN diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 93d1849f4513d38eef57439270e76c8b36cd8843..62dfacc7549b3a9310b34ad7c796c58b8ad21398 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -1,6 +1,11 @@ """Test the Z-Wave JS climate platform.""" import pytest +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.thermostat import ( + THERMOSTAT_OPERATING_STATE_PROPERTY, +) from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -25,6 +30,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.zwave_js.climate import ATTR_FAN_STATE +from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, @@ -37,6 +43,7 @@ from .common import ( CLIMATE_FLOOR_THERMOSTAT_ENTITY, CLIMATE_MAIN_HEAT_ACTIONNER, CLIMATE_RADIO_THERMOSTAT_ENTITY, + replace_value_of_zwave_value, ) @@ -637,3 +644,25 @@ async def test_temp_unit_fix( state = hass.states.get("climate.z_wave_thermostat") assert state assert state.attributes["current_temperature"] == 21.1 + + +async def test_thermostat_unknown_values( + hass, client, climate_radio_thermostat_ct100_plus_state, integration +): + """Test a thermostat v2 with unknown values.""" + node_state = replace_value_of_zwave_value( + climate_radio_thermostat_ct100_plus_state, + [ + ZwaveValueMatcher( + THERMOSTAT_OPERATING_STATE_PROPERTY, + command_class=CommandClass.THERMOSTAT_OPERATING_STATE, + ) + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY) + + assert ATTR_HVAC_ACTION not in state.attributes diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index d4f159f2510e6ef28a0f51b52f22846b0dd82f70..f58b41874691087ae52ac2ae3509aeeacdaa603c 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE -from homeassistant.components.zwave_js.const import DOMAIN +from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN from tests.common import MockConfigEntry @@ -162,7 +162,12 @@ def mock_list_ports_fixture(serial_port) -> Generator[MagicMock, None, None]: another_port.description = "New serial port" another_port.serial_number = "5678" another_port.pid = 8765 - mock_list_ports.return_value = [serial_port, another_port] + no_vid_port = copy(serial_port) + no_vid_port.device = "/no_vid" + no_vid_port.description = "Port without vid" + no_vid_port.serial_number = "9123" + no_vid_port.vid = None + mock_list_ports.return_value = [serial_port, another_port, no_vid_port] yield mock_list_ports @@ -326,7 +331,11 @@ async def test_supervisor_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) with patch( @@ -366,7 +375,11 @@ async def test_supervisor_discovery_cannot_connect( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result["type"] == "abort" @@ -388,7 +401,11 @@ async def test_clean_discovery_on_user_create( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result["type"] == "form" @@ -454,7 +471,11 @@ async def test_abort_discovery_with_existing_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result["type"] == "abort" @@ -478,13 +499,39 @@ async def test_abort_hassio_discovery_with_existing_flow( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result2["type"] == "abort" assert result2["reason"] == "already_in_progress" +async def test_abort_hassio_discovery_for_other_addon( + hass, supervisor, addon_installed, addon_options +): + """Test hassio discovery flow is aborted for a non official add-on discovery.""" + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=HassioServiceInfo( + config={ + "addon": "Other Z-Wave JS", + "host": "host1", + "port": 3001, + }, + name="Other Z-Wave JS", + slug="other_addon", + ), + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "not_zwave_js_addon" + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_usb_discovery( hass, @@ -673,7 +720,11 @@ async def test_discovery_addon_not_running( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result["step_id"] == "hassio_confirm" @@ -753,7 +804,11 @@ async def test_discovery_addon_not_installed( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result["step_id"] == "hassio_confirm" @@ -834,7 +889,11 @@ async def test_abort_usb_discovery_with_existing_flow(hass, supervisor, addon_op result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=ADDON_DISCOVERY_INFO), + data=HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="Z-Wave JS", + slug=ADDON_SLUG, + ), ) assert result["type"] == "form" diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index 54f71fa00d371c2de9946e944b4c13081b947ba2..f26b0d290691300e1a4f2eda644ec539d2af0e11 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -1,5 +1,11 @@ """Test the Z-Wave JS cover platform.""" +from zwave_js_server.const import ( + CURRENT_STATE_PROPERTY, + CURRENT_VALUE_PROPERTY, + CommandClass, +) from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, @@ -9,6 +15,7 @@ from homeassistant.components.cover import ( SERVICE_OPEN_COVER, CoverDeviceClass, ) +from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import ( ATTR_DEVICE_CLASS, STATE_CLOSED, @@ -18,6 +25,8 @@ from homeassistant.const import ( STATE_UNKNOWN, ) +from .common import replace_value_of_zwave_value + WINDOW_COVER_ENTITY = "cover.zws_12" GDC_COVER_ENTITY = "cover.aeon_labs_garage_door_controller_gen5" BLIND_COVER_ENTITY = "cover.window_blind_controller" @@ -600,3 +609,59 @@ async def test_motor_barrier_cover(hass, client, gdc_zw062, integration): state = hass.states.get(GDC_COVER_ENTITY) assert state.state == STATE_UNKNOWN + + +async def test_motor_barrier_cover_no_primary_value( + hass, client, gdc_zw062_state, integration +): + """Test the cover entity where primary value value is None.""" + node_state = replace_value_of_zwave_value( + gdc_zw062_state, + [ + ZwaveValueMatcher( + property_=CURRENT_STATE_PROPERTY, + command_class=CommandClass.BARRIER_OPERATOR, + ) + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + state = hass.states.get(GDC_COVER_ENTITY) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.GARAGE + + assert state.state == STATE_UNKNOWN + assert ATTR_CURRENT_POSITION not in state.attributes + + +async def test_fibaro_FGR222_shutter_cover_no_tilt( + hass, client, fibaro_fgr222_shutter_state, integration +): + """Test tilt function of the Fibaro Shutter devices with tilt value is None.""" + node_state = replace_value_of_zwave_value( + fibaro_fgr222_shutter_state, + [ + ZwaveValueMatcher( + property_="fibaro", + command_class=CommandClass.MANUFACTURER_PROPRIETARY, + property_key="venetianBlindsTilt", + ), + ZwaveValueMatcher( + property_=CURRENT_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + ), + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + assert state + assert state.state == STATE_UNKNOWN + assert ATTR_CURRENT_POSITION not in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 5f35a568f371997e065dc7447a745032c2904e0b..03ebd3b6453e8088ce5d8861b7747a7610c96f95 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -1,7 +1,12 @@ """Test the Z-Wave JS lock platform.""" -from zwave_js_server.const.command_class.lock import ATTR_CODE_SLOT, ATTR_USERCODE +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.lock import ( + ATTR_CODE_SLOT, + ATTR_USERCODE, + CURRENT_MODE_PROPERTY, +) from zwave_js_server.event import Event -from zwave_js_server.model.node import NodeStatus +from zwave_js_server.model.node import Node, NodeStatus from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, @@ -9,6 +14,7 @@ from homeassistant.components.lock import ( SERVICE_UNLOCK, ) from homeassistant.components.zwave_js.const import DOMAIN as ZWAVE_JS_DOMAIN +from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.components.zwave_js.lock import ( SERVICE_CLEAR_LOCK_USERCODE, SERVICE_SET_LOCK_USERCODE, @@ -17,10 +23,11 @@ from homeassistant.const import ( ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNAVAILABLE, + STATE_UNKNOWN, STATE_UNLOCKED, ) -from .common import SCHLAGE_BE469_LOCK_ENTITY +from .common import SCHLAGE_BE469_LOCK_ENTITY, replace_value_of_zwave_value async def test_door_lock(hass, client, lock_schlage_be469, integration): @@ -160,3 +167,23 @@ async def test_door_lock(hass, client, lock_schlage_be469, integration): async def test_only_one_lock(hass, client, lock_home_connect_620, integration): """Test node with both Door Lock and Lock CC values only gets one lock entity.""" assert len(hass.states.async_entity_ids("lock")) == 1 + + +async def test_door_lock_no_value(hass, client, lock_schlage_be469_state, integration): + """Test a lock entity with door lock command class that has no value for mode.""" + node_state = replace_value_of_zwave_value( + lock_schlage_be469_state, + [ + ZwaveValueMatcher( + property_=CURRENT_MODE_PROPERTY, + command_class=CommandClass.DOOR_LOCK, + ) + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + state = hass.states.get(SCHLAGE_BE469_LOCK_ENTITY) + assert state + assert state.state == STATE_UNKNOWN diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index 1cf5fb54304dff2cf4c98108c093e9e766d5f5fe..e278c11ca7252d76f6e80b53f290350239b092f5 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -1,15 +1,19 @@ """Test the Z-Wave JS number platform.""" from unittest.mock import MagicMock +from zwave_js_server.const import CURRENT_VALUE_PROPERTY, CommandClass from zwave_js_server.event import Event from zwave_js_server.model.node import Node +from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory import homeassistant.helpers.entity_registry as er +from .common import replace_value_of_zwave_value + DEFAULT_TONE_SELECT_ENTITY = "select.indoor_siren_6_default_tone_2" PROTECTION_SELECT_ENTITY = "select.family_room_combo_local_protection_state" MULTILEVEL_SWITCH_SELECT_ENTITY = "select.front_door_siren" @@ -265,3 +269,27 @@ async def test_multilevel_switch_select(hass, client, fortrezz_ssa1_siren, integ state = hass.states.get(MULTILEVEL_SWITCH_SELECT_ENTITY) assert state.state == "Strobe ONLY" + + +async def test_multilevel_switch_select_no_value( + hass, client, fortrezz_ssa1_siren_state, integration +): + """Test Multilevel Switch CC based select entity with primary value is None.""" + node_state = replace_value_of_zwave_value( + fortrezz_ssa1_siren_state, + [ + ZwaveValueMatcher( + property_=CURRENT_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + ) + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + state = hass.states.get(MULTILEVEL_SWITCH_SELECT_ENTITY) + + assert state + assert state.state == STATE_UNKNOWN diff --git a/tests/components/zwave_js/test_switch.py b/tests/components/zwave_js/test_switch.py index b84ab32f618b4bc9bc71f85f0f90b433ff1e4042..d485c877cc42f0f134d996d6e5505ab668048e5a 100644 --- a/tests/components/zwave_js/test_switch.py +++ b/tests/components/zwave_js/test_switch.py @@ -1,11 +1,14 @@ """Test the Z-Wave JS switch platform.""" +from zwave_js_server.const import CURRENT_VALUE_PROPERTY, CommandClass from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.switch import DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN -from .common import SWITCH_ENTITY +from .common import SWITCH_ENTITY, replace_value_of_zwave_value async def test_switch(hass, hank_binary_switch, integration, client): @@ -14,7 +17,7 @@ async def test_switch(hass, hank_binary_switch, integration, client): node = hank_binary_switch assert state - assert state.state == "off" + assert state.state == STATE_OFF # Test turning on await hass.services.async_call( @@ -178,3 +181,25 @@ async def test_barrier_signaling_switch(hass, gdc_zw062, integration, client): state = hass.states.get(entity) assert state.state == STATE_ON + + +async def test_switch_no_value(hass, hank_binary_switch_state, integration, client): + """Test the switch where primary value value is None.""" + node_state = replace_value_of_zwave_value( + hank_binary_switch_state, + [ + ZwaveValueMatcher( + property_=CURRENT_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_BINARY, + ) + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + state = hass.states.get(SWITCH_ENTITY) + + assert state + assert state.state == STATE_UNKNOWN diff --git a/tests/conftest.py b/tests/conftest.py index dacd9d09c9188e098991c78bccb670addd9386aa..1047293ee1618f3b1e16753d2e6f2960a77dbe3e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -79,6 +79,11 @@ asyncio.set_event_loop_policy(runner.HassEventLoopPolicy(False)) asyncio.set_event_loop_policy = lambda policy: None +def pytest_addoption(parser): + """Register custom pytest options.""" + parser.addoption("--dburl", action="store", default="sqlite://") + + def pytest_configure(config): """Register marker for tests that log exceptions.""" config.addinivalue_line( @@ -108,8 +113,19 @@ def pytest_runtest_setup(): def adapt_datetime(val): return val.isoformat(" ") + # Setup HAFakeDatetime converter for sqlite3 sqlite3.register_adapter(HAFakeDatetime, adapt_datetime) + # Setup HAFakeDatetime converter for pymysql + try: + import MySQLdb.converters as MySQLdb_converters + except ImportError: + pass + else: + MySQLdb_converters.conversions[ + HAFakeDatetime + ] = MySQLdb_converters.DateTime2literal + def ha_datetime_to_fakedatetime(datetime): """Convert datetime to FakeDatetime. @@ -312,9 +328,17 @@ def aiohttp_client( @pytest.fixture -def hass(loop, load_registries, hass_storage, request): +def hass_fixture_setup(): + """Fixture whichis truthy if the hass fixture has been setup.""" + return [] + + +@pytest.fixture +def hass(hass_fixture_setup, loop, load_registries, hass_storage, request): """Fixture to provide a test instance of Home Assistant.""" + hass_fixture_setup.append(True) + orig_tz = dt_util.DEFAULT_TIME_ZONE def exc_handle(loop, context): @@ -857,7 +881,29 @@ def recorder_config(): @pytest.fixture -def hass_recorder(enable_nightly_purge, enable_statistics, hass_storage): +def recorder_db_url(pytestconfig): + """Prepare a default database for tests and return a connection URL.""" + db_url: str = pytestconfig.getoption("dburl") + if db_url.startswith("mysql://"): + import sqlalchemy_utils + + charset = "utf8mb4' COLLATE = 'utf8mb4_unicode_ci" + assert not sqlalchemy_utils.database_exists(db_url) + sqlalchemy_utils.create_database(db_url, encoding=charset) + elif db_url.startswith("postgresql://"): + pass + yield db_url + if db_url.startswith("mysql://"): + sqlalchemy_utils.drop_database(db_url) + + +@pytest.fixture +def hass_recorder( + recorder_db_url, + enable_nightly_purge, + enable_statistics, + hass_storage, +): """Home Assistant fixture with in-memory recorder.""" original_tz = dt_util.DEFAULT_TIME_ZONE @@ -876,7 +922,7 @@ def hass_recorder(enable_nightly_purge, enable_statistics, hass_storage): def setup_recorder(config=None): """Set up with params.""" - init_recorder_component(hass, config) + init_recorder_component(hass, config, recorder_db_url) hass.start() hass.block_till_done() hass.data[recorder.DATA_INSTANCE].block_till_done() @@ -889,17 +935,15 @@ def hass_recorder(enable_nightly_purge, enable_statistics, hass_storage): dt_util.DEFAULT_TIME_ZONE = original_tz -async def _async_init_recorder_component(hass, add_config=None): +async def _async_init_recorder_component(hass, add_config=None, db_url=None): """Initialize the recorder asynchronously.""" config = dict(add_config) if add_config else {} if recorder.CONF_DB_URL not in config: - config[recorder.CONF_DB_URL] = "sqlite://" # In memory DB + config[recorder.CONF_DB_URL] = db_url if recorder.CONF_COMMIT_INTERVAL not in config: config[recorder.CONF_COMMIT_INTERVAL] = 0 - with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), patch( - "homeassistant.components.recorder.migration.migrate_schema" - ): + with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True): if recorder.DOMAIN not in hass.data: recorder_helper.async_initialize_recorder(hass) assert await async_setup_component( @@ -914,9 +958,13 @@ async def _async_init_recorder_component(hass, add_config=None): @pytest.fixture async def async_setup_recorder_instance( - enable_nightly_purge, enable_statistics + recorder_db_url, + hass_fixture_setup, + enable_nightly_purge, + enable_statistics, ) -> AsyncGenerator[SetupRecorderInstanceT, None]: """Yield callable to setup recorder instance.""" + assert not hass_fixture_setup nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None @@ -934,7 +982,7 @@ async def async_setup_recorder_instance( hass: HomeAssistant, config: ConfigType | None = None ) -> recorder.Recorder: """Setup and return recorder instance.""" # noqa: D401 - await _async_init_recorder_component(hass, config) + await _async_init_recorder_component(hass, config, recorder_db_url) await hass.async_block_till_done() instance = hass.data[recorder.DATA_INSTANCE] # The recorder's worker is not started until Home Assistant is running diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index b7e4caf68c766f5d389039a906d40c3cafc3c6bc..65db1291f9db5e39e87600c73b24685bd911a58b 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -42,7 +42,7 @@ def setup_comp(hass): """Initialize components.""" hass.config.set_time_zone(hass.config.time_zone) hass.loop.run_until_complete( - async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) + async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) ) @@ -2735,9 +2735,9 @@ async def test_if_action_after_sunset_with_offset(hass, hass_ws_client, calls): ) -async def test_if_action_before_and_after_during(hass, hass_ws_client, calls): +async def test_if_action_after_and_before_during(hass, hass_ws_client, calls): """ - Test if action was after sunset and before sunrise. + Test if action was after sunrise and before sunset. This is true from sunrise until sunset. """ @@ -2837,6 +2837,128 @@ async def test_if_action_before_and_after_during(hass, hass_ws_client, calls): ) +async def test_if_action_before_or_after_during(hass, hass_ws_client, calls): + """ + Test if action was before sunrise or after sunset. + + This is true from midnight until sunrise and from sunset until midnight + """ + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "sun", + "before": SUN_EVENT_SUNRISE, + "after": SUN_EVENT_SUNSET, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + # sunrise: 2015-09-16 06:33:18 local, sunset: 2015-09-16 18:53:45 local + # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC + # now = sunrise - 1s -> 'before sunrise' | 'after sunset' true + now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunset + 1s -> 'before sunrise' | 'after sunset' true + now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunrise + 1s -> 'before sunrise' | 'after sunset' false + now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": False, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = sunset - 1s -> 'before sunrise' | 'after sunset' false + now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 2 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": False, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = midnight + 1s local -> 'before sunrise' | 'after sunset' true + now = datetime(2015, 9, 16, 7, 0, 1, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 3 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + # now = midnight - 1s local -> 'before sunrise' | 'after sunset' true + now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 4 + await assert_automation_condition_trace( + hass_ws_client, + "sun", + { + "result": True, + "wanted_time_after": "2015-09-17T01:53:44.723614+00:00", + "wanted_time_before": "2015-09-16T13:33:18.342542+00:00", + }, + ) + + async def test_if_action_before_sunrise_no_offset_kotzebue(hass, hass_ws_client, calls): """ Test if action was before sunrise. diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 57b89e64cce40fea91cd8915c210c37c8d0f68fc..536ccaaac68085bcacc0963798f18b87beb65b81 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3333,9 +3333,7 @@ async def test_track_sunrise(hass): # Setup sun component hass.config.latitude = latitude hass.config.longitude = longitude - assert await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) location = LocationInfo( latitude=hass.config.latitude, longitude=hass.config.longitude @@ -3400,9 +3398,7 @@ async def test_track_sunrise_update_location(hass): # Setup sun component hass.config.latitude = 32.87336 hass.config.longitude = 117.22743 - assert await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) location = LocationInfo( latitude=hass.config.latitude, longitude=hass.config.longitude @@ -3476,9 +3472,7 @@ async def test_track_sunset(hass): # Setup sun component hass.config.latitude = latitude hass.config.longitude = longitude - assert await async_setup_component( - hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}} - ) + assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) # Get next sunrise/sunset utc_now = datetime(2014, 5, 24, 12, 0, 0, tzinfo=dt_util.UTC) diff --git a/tests/helpers/test_recorder.py b/tests/helpers/test_recorder.py index 9410117acb490f413a3d134d4aaf98a6552b32f7..9c11a372cbeb97372d0ee75f7151b8dc3c9c2a98 100644 --- a/tests/helpers/test_recorder.py +++ b/tests/helpers/test_recorder.py @@ -9,7 +9,7 @@ from tests.common import SetupRecorderInstanceT async def test_async_migration_in_progress( - hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT + async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): """Test async_migration_in_progress wraps the recorder.""" with patch( diff --git a/tests/helpers/test_start.py b/tests/helpers/test_start.py index bc32ffa35fdeb98c7e99e29eabe9d2bca3d98e04..bccf99a4274d7d719fc4b3a45807b4e99c6dd996 100644 --- a/tests/helpers/test_start.py +++ b/tests/helpers/test_start.py @@ -1,6 +1,6 @@ """Test starting HA helpers.""" from homeassistant import core -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED from homeassistant.helpers import start @@ -100,7 +100,7 @@ async def test_at_start_when_starting_callback(hass, caplog): assert record.levelname in ("DEBUG", "INFO") -async def test_cancelling_when_running(hass, caplog): +async def test_cancelling_at_start_when_running(hass, caplog): """Test cancelling at start when already running.""" assert hass.state == core.CoreState.running assert hass.is_running @@ -120,7 +120,7 @@ async def test_cancelling_when_running(hass, caplog): assert record.levelname in ("DEBUG", "INFO") -async def test_cancelling_when_starting(hass): +async def test_cancelling_at_start_when_starting(hass): """Test cancelling at start when yet to start.""" hass.state = core.CoreState.not_running assert not hass.is_running @@ -139,3 +139,148 @@ async def test_cancelling_when_starting(hass): hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() assert len(calls) == 0 + + +async def test_at_started_when_running_awaitable(hass): + """Test at started when already started.""" + assert hass.state == core.CoreState.running + + calls = [] + + async def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + start.async_at_started(hass, cb_at_start) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Test the job is not run if state is CoreState.starting + hass.state = core.CoreState.starting + + start.async_at_started(hass, cb_at_start) + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_at_started_when_running_callback(hass, caplog): + """Test at started when already running.""" + assert hass.state == core.CoreState.running + + calls = [] + + @core.callback + def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + start.async_at_started(hass, cb_at_start)() + assert len(calls) == 1 + + # Test the job is not run if state is CoreState.starting + hass.state = core.CoreState.starting + + start.async_at_started(hass, cb_at_start)() + assert len(calls) == 1 + + # Check the unnecessary cancel did not generate warnings or errors + for record in caplog.records: + assert record.levelname in ("DEBUG", "INFO") + + +async def test_at_started_when_starting_awaitable(hass): + """Test at started when yet to start.""" + hass.state = core.CoreState.not_running + + calls = [] + + async def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + start.async_at_started(hass, cb_at_start) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_at_started_when_starting_callback(hass, caplog): + """Test at started when yet to start.""" + hass.state = core.CoreState.not_running + + calls = [] + + @core.callback + def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + cancel = start.async_at_started(hass, cb_at_start) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert len(calls) == 1 + + cancel() + + # Check the unnecessary cancel did not generate warnings or errors + for record in caplog.records: + assert record.levelname in ("DEBUG", "INFO") + + +async def test_cancelling_at_started_when_running(hass, caplog): + """Test cancelling at start when already running.""" + assert hass.state == core.CoreState.running + assert hass.is_running + + calls = [] + + async def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + start.async_at_started(hass, cb_at_start)() + await hass.async_block_till_done() + assert len(calls) == 1 + + # Check the unnecessary cancel did not generate warnings or errors + for record in caplog.records: + assert record.levelname in ("DEBUG", "INFO") + + +async def test_cancelling_at_started_when_starting(hass): + """Test cancelling at start when yet to start.""" + hass.state = core.CoreState.not_running + assert not hass.is_running + + calls = [] + + @core.callback + def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + start.async_at_started(hass, cb_at_start)() + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert len(calls) == 0 diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index fa9ec4e76d67eb7cb564da90a26183bf39a01509..63a6154a190333d531c49262b833277b40a9ffa1 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -43,13 +43,14 @@ def _set_up_units(hass): """Set up the tests.""" hass.config.units = UnitSystem( "custom", - TEMP_CELSIUS, - LENGTH_METERS, - SPEED_KILOMETERS_PER_HOUR, - VOLUME_LITERS, - MASS_GRAMS, - PRESSURE_PA, - LENGTH_MILLIMETERS, + accumulated_precipitation=LENGTH_MILLIMETERS, + conversions={}, + length=LENGTH_METERS, + mass=MASS_GRAMS, + pressure=PRESSURE_PA, + temperature=TEMP_CELSIUS, + volume=VOLUME_LITERS, + wind_speed=SPEED_KILOMETERS_PER_HOUR, ) @@ -973,12 +974,27 @@ def test_average(hass): assert template.Template("{{ average([1, 2, 3]) }}", hass).async_render() == 2 assert template.Template("{{ average(1, 2, 3) }}", hass).async_render() == 2 + # Testing of default values + assert template.Template("{{ average([1, 2, 3], -1) }}", hass).async_render() == 2 + assert template.Template("{{ average([], -1) }}", hass).async_render() == -1 + assert template.Template("{{ average([], default=-1) }}", hass).async_render() == -1 + assert ( + template.Template("{{ average([], 5, default=-1) }}", hass).async_render() == -1 + ) + assert ( + template.Template("{{ average(1, 'a', 3, default=-1) }}", hass).async_render() + == -1 + ) + with pytest.raises(TemplateError): template.Template("{{ 1 | average }}", hass).async_render() with pytest.raises(TemplateError): template.Template("{{ average() }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ average([]) }}", hass).async_render() + def test_min(hass): """Test the min filter.""" @@ -1332,6 +1348,22 @@ def test_is_state(hass): ) assert tpl.async_render() is False + tpl = template.Template( + """ +{% if "test.object" is is_state("available") %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + tpl = template.Template( + """ +{{ ['test.object'] | select("is_state", "available") | first | default }} + """, + hass, + ) + assert tpl.async_render() == "test.object" + def test_is_state_attr(hass): """Test is_state_attr method.""" @@ -1352,10 +1384,28 @@ def test_is_state_attr(hass): ) assert tpl.async_render() is False + tpl = template.Template( + """ +{% if "test.object" is is_state_attr("mode", "on") %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + tpl = template.Template( + """ +{{ ['test.object'] | select("is_state_attr", "mode", "on") | first | default }} + """, + hass, + ) + assert tpl.async_render() == "test.object" + def test_state_attr(hass): """Test state_attr method.""" - hass.states.async_set("test.object", "available", {"mode": "on"}) + hass.states.async_set( + "test.object", "available", {"effect": "action", "mode": "on"} + ) tpl = template.Template( """ {% if state_attr("test.object", "mode") == "on" %}yes{% else %}no{% endif %} @@ -1372,6 +1422,22 @@ def test_state_attr(hass): ) assert tpl.async_render() is True + tpl = template.Template( + """ +{% if "test.object" | state_attr("mode") == "on" %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + tpl = template.Template( + """ +{{ ['test.object'] | map("state_attr", "effect") | first | default }} + """, + hass, + ) + assert tpl.async_render() == "action" + def test_states_function(hass): """Test using states as a function.""" @@ -1382,6 +1448,22 @@ def test_states_function(hass): tpl2 = template.Template('{{ states("test.object2") }}', hass) assert tpl2.async_render() == "unknown" + tpl = template.Template( + """ +{% if "test.object" | states == "available" %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + tpl = template.Template( + """ +{{ ['test.object'] | map("states") | first | default }} + """, + hass, + ) + assert tpl.async_render() == "available" + @patch( "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", @@ -2458,6 +2540,32 @@ async def test_integration_entities(hass): assert info.rate_limit is None +async def test_config_entry_id(hass): + """Test config_entry_id function.""" + config_entry = MockConfigEntry(domain="light", title="Some integration") + config_entry.add_to_hass(hass) + entity_registry = mock_registry(hass) + entity_entry = entity_registry.async_get_or_create( + "sensor", "test", "test", suggested_object_id="test", config_entry=config_entry + ) + + info = render_to_info(hass, "{{ 'sensor.fail' | config_entry_id }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 56 | config_entry_id }}") + assert_result_info(info, None) + + info = render_to_info(hass, "{{ 'not_a_real_entity_id' | config_entry_id }}") + assert_result_info(info, None) + + info = render_to_info( + hass, f"{{{{ config_entry_id('{entity_entry.entity_id}') }}}}" + ) + assert_result_info(info, config_entry.entry_id) + assert info.rate_limit is None + + async def test_device_id(hass): """Test device_id function.""" config_entry = MockConfigEntry(domain="light") diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 56c15f493376b69a98b280bc848ef82f96a78f36..e51f4d315eefcf7ae4decefb960f1c04c70e92fe 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -9,7 +9,7 @@ import pytest from homeassistant import bootstrap, core, runner import homeassistant.config as config_util -from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATONS +from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -748,7 +748,7 @@ async def test_empty_integrations_list_is_only_sent_at_the_end_of_bootstrap(hass integrations.append(data) async_dispatcher_connect( - hass, SIGNAL_BOOTSTRAP_INTEGRATONS, _bootstrap_integrations + hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, _bootstrap_integrations ) with patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.05): await bootstrap._async_set_up_integrations( diff --git a/tests/test_config.py b/tests/test_config.py index 9b3f9d8755fa15d3d5e0da4fdfb2ea83f032984a..0a125d8f121a35ce128c6d6732dcf26503e9b4b5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,17 +22,22 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, - CONF_TEMPERATURE_UNIT, CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, __version__, ) -from homeassistant.core import ConfigSource, HomeAssistantError +from homeassistant.core import ConfigSource, HomeAssistant, HomeAssistantError from homeassistant.helpers import config_validation as cv import homeassistant.helpers.check_config as check_config from homeassistant.helpers.entity import Entity from homeassistant.loader import async_get_integration +from homeassistant.util.unit_system import ( + _CONF_UNIT_SYSTEM_US_CUSTOMARY, + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from homeassistant.util.yaml import SECRET_YAML from tests.common import get_test_config_dir, patch_yaml_files @@ -440,7 +445,7 @@ async def test_loading_configuration_from_storage_with_yaml_only(hass, hass_stor assert hass.config.config_source is ConfigSource.STORAGE -async def test_updating_configuration(hass, hass_storage): +async def test_igration_and_updating_configuration(hass, hass_storage): """Test updating configuration stores the new configuration.""" core_data = { "data": { @@ -449,7 +454,7 @@ async def test_updating_configuration(hass, hass_storage): "location_name": "Home", "longitude": 13, "time_zone": "Europe/Copenhagen", - "unit_system": "metric", + "unit_system": "imperial", "external_url": "https://www.example.com", "internal_url": "http://example.local", "currency": "BTC", @@ -464,10 +469,14 @@ async def test_updating_configuration(hass, hass_storage): ) await hass.config.async_update(latitude=50, currency="USD") - new_core_data = copy.deepcopy(core_data) - new_core_data["data"]["latitude"] = 50 - new_core_data["data"]["currency"] = "USD" - assert hass_storage["core.config"] == new_core_data + expected_new_core_data = copy.deepcopy(core_data) + # From async_update above + expected_new_core_data["data"]["latitude"] = 50 + expected_new_core_data["data"]["currency"] = "USD" + # 1.1 -> 1.2 store migration with migrated unit system + expected_new_core_data["data"]["unit_system_v2"] = "us_customary" + expected_new_core_data["minor_version"] = 2 + assert hass_storage["core.config"] == expected_new_core_data assert hass.config.latitude == 50 assert hass.config.currency == "USD" @@ -538,34 +547,6 @@ async def test_loading_configuration(hass): assert hass.config.currency == "EUR" -async def test_loading_configuration_temperature_unit(hass): - """Test backward compatibility when loading core config.""" - await config_util.async_process_ha_core_config( - hass, - { - "latitude": 60, - "longitude": 50, - "elevation": 25, - "name": "Huis", - CONF_TEMPERATURE_UNIT: "C", - "time_zone": "America/New_York", - "external_url": "https://www.example.com", - "internal_url": "http://example.local", - }, - ) - - assert hass.config.latitude == 60 - assert hass.config.longitude == 50 - assert hass.config.elevation == 25 - assert hass.config.location_name == "Huis" - assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC - assert hass.config.time_zone == "America/New_York" - assert hass.config.external_url == "https://www.example.com" - assert hass.config.internal_url == "http://example.local" - assert hass.config.config_source is ConfigSource.YAML - assert hass.config.currency == "EUR" - - async def test_loading_configuration_default_media_dirs_docker(hass): """Test loading core config onto hass object.""" with patch("homeassistant.config.is_docker_env", return_value=True): @@ -591,7 +572,7 @@ async def test_loading_configuration_from_packages(hass): "longitude": -1, "elevation": 500, "name": "Huis", - CONF_TEMPERATURE_UNIT: "C", + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, "time_zone": "Europe/Madrid", "external_url": "https://www.example.com", "internal_url": "http://example.local", @@ -615,13 +596,42 @@ async def test_loading_configuration_from_packages(hass): "longitude": -1, "elevation": 500, "name": "Huis", - CONF_TEMPERATURE_UNIT: "C", + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, "time_zone": "Europe/Madrid", "packages": {"empty_package": None}, }, ) +@pytest.mark.parametrize( + "unit_system_name, expected_unit_system", + [ + (CONF_UNIT_SYSTEM_METRIC, METRIC_SYSTEM), + (CONF_UNIT_SYSTEM_IMPERIAL, US_CUSTOMARY_SYSTEM), + (_CONF_UNIT_SYSTEM_US_CUSTOMARY, US_CUSTOMARY_SYSTEM), + ], +) +async def test_loading_configuration_unit_system( + hass: HomeAssistant, unit_system_name: str, expected_unit_system: UnitSystem +) -> None: + """Test backward compatibility when loading core config.""" + await config_util.async_process_ha_core_config( + hass, + { + "latitude": 60, + "longitude": 50, + "elevation": 25, + "name": "Huis", + "unit_system": unit_system_name, + "time_zone": "America/New_York", + "external_url": "https://www.example.com", + "internal_url": "http://example.local", + }, + ) + + assert hass.config.units is expected_unit_system + + @patch("homeassistant.helpers.check_config.async_check_ha_config_file") async def test_check_ha_config_file_correct(mock_check, hass): """Check that restart propagates to stop.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index d2d4ffe1134f6c710453461f1d9e88bf052a42a2..83343146d47030f8ea3a4243481851ffdf889f91 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2507,7 +2507,10 @@ async def test_async_setup_update_entry(hass): (config_entries.SOURCE_HOMEKIT, BaseServiceInfo()), (config_entries.SOURCE_DHCP, BaseServiceInfo()), (config_entries.SOURCE_ZEROCONF, BaseServiceInfo()), - (config_entries.SOURCE_HASSIO, HassioServiceInfo(config={})), + ( + config_entries.SOURCE_HASSIO, + HassioServiceInfo(config={}, name="Test", slug="test"), + ), ), ) async def test_flow_with_default_discovery(hass, manager, discovery_source): diff --git a/tests/test_core.py b/tests/test_core.py index 67513ea8b17b81312a9a877df1b96a0fd73a5b55..017c8b3b607deef69dd49adf673b6103d70e2aff 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -925,7 +925,7 @@ async def test_serviceregistry_callback_service_raise_exception(hass): await hass.async_block_till_done() -def test_config_defaults(): +async def test_config_defaults(): """Test config defaults.""" hass = Mock() config = ha.Config(hass) @@ -950,21 +950,21 @@ def test_config_defaults(): assert config.currency == "EUR" -def test_config_path_with_file(): +async def test_config_path_with_file(): """Test get_config_path method.""" config = ha.Config(None) config.config_dir = "/test/ha-config" assert config.path("test.conf") == "/test/ha-config/test.conf" -def test_config_path_with_dir_and_file(): +async def test_config_path_with_dir_and_file(): """Test get_config_path method.""" config = ha.Config(None) config.config_dir = "/test/ha-config" assert config.path("dir", "test.conf") == "/test/ha-config/dir/test.conf" -def test_config_as_dict(): +async def test_config_as_dict(): """Test as dict.""" config = ha.Config(None) config.config_dir = "/test/ha-config" @@ -994,7 +994,7 @@ def test_config_as_dict(): assert expected == config.as_dict() -def test_config_is_allowed_path(): +async def test_config_is_allowed_path(): """Test is_allowed_path method.""" config = ha.Config(None) with TemporaryDirectory() as tmp_dir: @@ -1026,7 +1026,7 @@ def test_config_is_allowed_path(): config.is_allowed_path(None) -def test_config_is_allowed_external_url(): +async def test_config_is_allowed_external_url(): """Test is_allowed_external_url method.""" config = ha.Config(None) config.allowlist_external_urls = [ @@ -1273,7 +1273,7 @@ async def test_additional_data_in_core_config(hass, hass_storage): async def test_incorrect_internal_external_url(hass, hass_storage, caplog): - """Test that we warn when detecting invalid internal/extenral url.""" + """Test that we warn when detecting invalid internal/external url.""" config = ha.Config(hass) hass_storage[ha.CORE_STORAGE_KEY] = { @@ -1287,6 +1287,8 @@ async def test_incorrect_internal_external_url(hass, hass_storage, caplog): assert "Invalid external_url set" not in caplog.text assert "Invalid internal_url set" not in caplog.text + config = ha.Config(hass) + hass_storage[ha.CORE_STORAGE_KEY] = { "version": 1, "data": { diff --git a/tests/testing_config/custom_components/test/light.py b/tests/testing_config/custom_components/test/light.py index a4b5a182edc73dd05e07b8a6e4e375b6937f37fe..3c78e7acce366ce49cf6040ab2de6f4e244b6fcf 100644 --- a/tests/testing_config/custom_components/test/light.py +++ b/tests/testing_config/custom_components/test/light.py @@ -37,13 +37,13 @@ class MockLight(MockToggleEntity, LightEntity): """Mock light class.""" color_mode = None - max_mireds = 500 - min_mireds = 153 + _attr_max_color_temp_kelvin = 6500 + _attr_min_color_temp_kelvin = 2000 supported_color_modes = None supported_features = 0 brightness = None - color_temp = None + color_temp_kelvin = None hs_color = None rgb_color = None rgbw_color = None @@ -61,7 +61,7 @@ class MockLight(MockToggleEntity, LightEntity): "rgb_color", "rgbw_color", "rgbww_color", - "color_temp", + "color_temp_kelvin", ]: setattr(self, key, value) if key == "white": diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 6404a126807d35d90d50f0c9724647d887527954..9584e47ba0b0b810e1aeb9623ba4156fc51ce960 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -112,6 +112,11 @@ class MockSensor(MockEntity, SensorEntity): """Return the state class of this sensor.""" return self._handle("state_class") + @property + def suggested_unit_of_measurement(self): + """Return the state class of this sensor.""" + return self._handle("suggested_unit_of_measurement") + class MockRestoreSensor(MockSensor, RestoreSensor): """Mock RestoreSensor class.""" diff --git a/tests/util/test_color.py b/tests/util/test_color.py index b77540acc2be6300bbd43e0b72cab4224bef4b07..95d2ffc0fd70f5b4bb2902dcdba9cc61d72faa89 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -370,82 +370,84 @@ def test_get_color_in_voluptuous(): def test_color_rgb_to_rgbww(): """Test color_rgb_to_rgbww conversions.""" - assert color_util.color_rgb_to_rgbww(255, 255, 255, 154, 370) == ( + # Light with mid point at ~4600K (warm white) -> output compensated by adding blue + assert color_util.color_rgb_to_rgbww(255, 255, 255, 2702, 6493) == ( 0, 54, 98, 255, 255, ) - assert color_util.color_rgb_to_rgbww(255, 255, 255, 100, 1000) == ( + # Light with mid point at ~5500K (less warm white) -> output compensated by adding less blue + assert color_util.color_rgb_to_rgbww(255, 255, 255, 1000, 10000) == ( 255, 255, 255, 0, 0, ) - assert color_util.color_rgb_to_rgbww(255, 255, 255, 1, 1000) == ( + # Light with mid point at ~1MK (unrealistically cold white) -> output compensated by adding red + assert color_util.color_rgb_to_rgbww(255, 255, 255, 1000, 1000000) == ( 0, 118, 241, 255, 255, ) - assert color_util.color_rgb_to_rgbww(128, 128, 128, 154, 370) == ( + assert color_util.color_rgb_to_rgbww(128, 128, 128, 2702, 6493) == ( 0, 27, 49, 128, 128, ) - assert color_util.color_rgb_to_rgbww(64, 64, 64, 154, 370) == (0, 14, 25, 64, 64) - assert color_util.color_rgb_to_rgbww(32, 64, 16, 154, 370) == (9, 64, 0, 38, 38) - assert color_util.color_rgb_to_rgbww(0, 0, 0, 154, 370) == (0, 0, 0, 0, 0) - assert color_util.color_rgb_to_rgbww(0, 0, 0, 0, 100) == (0, 0, 0, 0, 0) - assert color_util.color_rgb_to_rgbww(255, 255, 255, 1, 5) == (103, 69, 0, 255, 255) + assert color_util.color_rgb_to_rgbww(64, 64, 64, 2702, 6493) == (0, 14, 25, 64, 64) + assert color_util.color_rgb_to_rgbww(32, 64, 16, 2702, 6493) == (9, 64, 0, 38, 38) + assert color_util.color_rgb_to_rgbww(0, 0, 0, 2702, 6493) == (0, 0, 0, 0, 0) + assert color_util.color_rgb_to_rgbww(0, 0, 0, 10000, 1000000) == (0, 0, 0, 0, 0) + assert color_util.color_rgb_to_rgbww(255, 255, 255, 200000, 1000000) == ( + 103, + 69, + 0, + 255, + 255, + ) def test_color_rgbww_to_rgb(): """Test color_rgbww_to_rgb conversions.""" - assert color_util.color_rgbww_to_rgb(0, 54, 98, 255, 255, 154, 370) == ( + assert color_util.color_rgbww_to_rgb(0, 54, 98, 255, 255, 2702, 6493) == ( 255, 255, 255, ) - assert color_util.color_rgbww_to_rgb(255, 255, 255, 0, 0, 154, 370) == ( + # rgb fully on, + both white channels turned off -> rgb fully on + assert color_util.color_rgbww_to_rgb(255, 255, 255, 0, 0, 2702, 6493) == ( 255, 255, 255, ) - assert color_util.color_rgbww_to_rgb(0, 118, 241, 255, 255, 154, 370) == ( + # r < g < b + both white channels fully enabled -> r < g < b capped at 255 + assert color_util.color_rgbww_to_rgb(0, 118, 241, 255, 255, 2702, 6493) == ( 163, 204, 255, ) - assert color_util.color_rgbww_to_rgb(0, 27, 49, 128, 128, 154, 370) == ( + # r < g < b + both white channels 50% enabled -> r < g < b capped at 128 + assert color_util.color_rgbww_to_rgb(0, 27, 49, 128, 128, 2702, 6493) == ( 128, 128, 128, ) - assert color_util.color_rgbww_to_rgb(0, 14, 25, 64, 64, 154, 370) == (64, 64, 64) - assert color_util.color_rgbww_to_rgb(9, 64, 0, 38, 38, 154, 370) == (32, 64, 16) - assert color_util.color_rgbww_to_rgb(0, 0, 0, 0, 0, 154, 370) == (0, 0, 0) - assert color_util.color_rgbww_to_rgb(103, 69, 0, 255, 255, 153, 370) == ( + # r < g < b + both white channels 25% enabled -> r < g < b capped at 64 + assert color_util.color_rgbww_to_rgb(0, 14, 25, 64, 64, 2702, 6493) == (64, 64, 64) + assert color_util.color_rgbww_to_rgb(9, 64, 0, 38, 38, 2702, 6493) == (32, 64, 16) + assert color_util.color_rgbww_to_rgb(0, 0, 0, 0, 0, 2702, 6493) == (0, 0, 0) + assert color_util.color_rgbww_to_rgb(103, 69, 0, 255, 255, 2702, 6535) == ( 255, 193, 112, ) - assert color_util.color_rgbww_to_rgb(255, 255, 255, 0, 0, 0, 0) == (255, 255, 255) - assert color_util.color_rgbww_to_rgb(255, 255, 255, 255, 255, 0, 0) == ( - 255, - 161, - 128, - ) - assert color_util.color_rgbww_to_rgb(255, 255, 255, 255, 255, 0, 370) == ( - 255, - 245, - 237, - ) def test_color_temperature_to_rgbww(): @@ -454,42 +456,45 @@ def test_color_temperature_to_rgbww(): Temperature values must be in mireds Home Assistant uses rgbcw for rgbww """ - assert color_util.color_temperature_to_rgbww(153, 255, 153, 500) == ( + # Coldest color temperature -> only cold channel enabled + assert color_util.color_temperature_to_rgbww(6535, 255, 2000, 6535) == ( 0, 0, 0, 255, 0, ) - assert color_util.color_temperature_to_rgbww(153, 128, 153, 500) == ( + assert color_util.color_temperature_to_rgbww(6535, 128, 2000, 6535) == ( 0, 0, 0, 128, 0, ) - assert color_util.color_temperature_to_rgbww(500, 255, 153, 500) == ( + # Warmest color temperature -> only cold channel enabled + assert color_util.color_temperature_to_rgbww(2000, 255, 2000, 6535) == ( 0, 0, 0, 0, 255, ) - assert color_util.color_temperature_to_rgbww(500, 128, 153, 500) == ( + assert color_util.color_temperature_to_rgbww(2000, 128, 2000, 6535) == ( 0, 0, 0, 0, 128, ) - assert color_util.color_temperature_to_rgbww(347, 255, 153, 500) == ( + # Warmer than mid point color temperature -> More warm than cold channel enabled + assert color_util.color_temperature_to_rgbww(2881, 255, 2000, 6535) == ( 0, 0, 0, 112, 143, ) - assert color_util.color_temperature_to_rgbww(347, 128, 153, 500) == ( + assert color_util.color_temperature_to_rgbww(2881, 128, 2000, 6535) == ( 0, 0, 0, @@ -504,39 +509,36 @@ def test_rgbww_to_color_temperature(): Temperature values must be in mireds Home Assistant uses rgbcw for rgbww """ - assert color_util.rgbww_to_color_temperature( - ( - 0, - 0, - 0, - 255, - 0, - ), - 153, - 500, - ) == (153, 255) - assert color_util.rgbww_to_color_temperature((0, 0, 0, 128, 0), 153, 500) == ( - 153, + # Only cold channel enabled -> coldest color temperature + assert color_util.rgbww_to_color_temperature((0, 0, 0, 255, 0), 2000, 6535) == ( + 6535, + 255, + ) + assert color_util.rgbww_to_color_temperature((0, 0, 0, 128, 0), 2000, 6535) == ( + 6535, 128, ) - assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 255), 153, 500) == ( - 500, + # Only warm channel enabled -> warmest color temperature + assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 255), 2000, 6535) == ( + 2000, 255, ) - assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 128), 153, 500) == ( - 500, + assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 128), 2000, 6535) == ( + 2000, 128, ) - assert color_util.rgbww_to_color_temperature((0, 0, 0, 112, 143), 153, 500) == ( - 348, + # More warm than cold channel enabled -> warmer than mid point + assert color_util.rgbww_to_color_temperature((0, 0, 0, 112, 143), 2000, 6535) == ( + 2876, 255, ) - assert color_util.rgbww_to_color_temperature((0, 0, 0, 56, 72), 153, 500) == ( - 348, + assert color_util.rgbww_to_color_temperature((0, 0, 0, 56, 72), 2000, 6535) == ( + 2872, 128, ) - assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 0), 153, 500) == ( - 500, + # Both channels turned off -> warmest color temperature + assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 0), 2000, 6535) == ( + 2000, 0, ) @@ -547,33 +549,34 @@ def test_white_levels_to_color_temperature(): Temperature values must be in mireds Home Assistant uses rgbcw for rgbww """ - assert color_util.while_levels_to_color_temperature( + # Only cold channel enabled -> coldest color temperature + assert color_util._white_levels_to_color_temperature(255, 0, 2000, 6535) == ( + 6535, 255, - 0, - 153, - 500, - ) == (153, 255) - assert color_util.while_levels_to_color_temperature(128, 0, 153, 500) == ( - 153, + ) + assert color_util._white_levels_to_color_temperature(128, 0, 2000, 6535) == ( + 6535, 128, ) - assert color_util.while_levels_to_color_temperature(0, 255, 153, 500) == ( - 500, + # Only warm channel enabled -> warmest color temperature + assert color_util._white_levels_to_color_temperature(0, 255, 2000, 6535) == ( + 2000, 255, ) - assert color_util.while_levels_to_color_temperature(0, 128, 153, 500) == ( - 500, + assert color_util._white_levels_to_color_temperature(0, 128, 2000, 6535) == ( + 2000, 128, ) - assert color_util.while_levels_to_color_temperature(112, 143, 153, 500) == ( - 348, + assert color_util._white_levels_to_color_temperature(112, 143, 2000, 6535) == ( + 2876, 255, ) - assert color_util.while_levels_to_color_temperature(56, 72, 153, 500) == ( - 348, + assert color_util._white_levels_to_color_temperature(56, 72, 2000, 6535) == ( + 2872, 128, ) - assert color_util.while_levels_to_color_temperature(0, 0, 153, 500) == ( - 500, + # Both channels turned off -> warmest color temperature + assert color_util._white_levels_to_color_temperature(0, 0, 2000, 6535) == ( + 2000, 0, ) diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 79cd4e5e0dfdeadab7988619bfc51f8e195d9fcd..e902176bb3547154e8ee4f13b197b27fd660df61 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta +import time import pytest @@ -719,3 +720,8 @@ def test_find_next_time_expression_tenth_second_pattern_does_not_drift_entering_ assert (next_target - prev_target).total_seconds() == 60 assert next_target.second == 10 prev_target = next_target + + +def test_monotonic_time_coarse(): + """Test monotonic time coarse.""" + assert abs(time.monotonic() - dt_util.monotonic_time_coarse()) < 1 diff --git a/tests/util/test_pressure.py b/tests/util/test_pressure.py index 769c5aaf80167a48e238d8e633421aad58a98d6e..f87b89df3f76932f658425e63db3cb11a773c239 100644 --- a/tests/util/test_pressure.py +++ b/tests/util/test_pressure.py @@ -118,7 +118,7 @@ def test_convert_from_inhg(): 101.59167 ) assert pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_MMHG) == pytest.approx( - 762.002 + 762 ) @@ -126,23 +126,23 @@ def test_convert_from_mmhg(): """Test conversion from mmHg to other units.""" inhg = 30 assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_PSI) == pytest.approx( - 0.580102 + 0.580103 ) assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_KPA) == pytest.approx( - 3.99966 + 3.99967 ) assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_HPA) == pytest.approx( - 39.9966 + 39.9967 ) assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_PA) == pytest.approx( - 3999.66 + 3999.67 ) assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_MBAR) == pytest.approx( - 39.9966 + 39.9967 ) assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_CBAR) == pytest.approx( - 3.99966 + 3.99967 ) assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_INHG) == pytest.approx( - 1.181099 + 1.181102 ) diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index ec839a6575c968059a5b2c7cfbb990ad97343ec1..b2b99d6f8c006b62012d08f2d05651ffaa90b95e 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -2,50 +2,15 @@ import pytest from homeassistant.const import ( - ENERGY_KILO_WATT_HOUR, - ENERGY_MEGA_WATT_HOUR, - ENERGY_WATT_HOUR, - LENGTH_CENTIMETERS, - LENGTH_FEET, - LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - LENGTH_MILLIMETERS, - LENGTH_YARD, - MASS_GRAMS, - MASS_KILOGRAMS, - MASS_MICROGRAMS, - MASS_MILLIGRAMS, - MASS_OUNCES, - MASS_POUNDS, - POWER_KILO_WATT, - POWER_WATT, - PRESSURE_CBAR, - PRESSURE_HPA, - PRESSURE_INHG, - PRESSURE_KPA, - PRESSURE_MBAR, - PRESSURE_MMHG, - PRESSURE_PA, - PRESSURE_PSI, - 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, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_KELVIN, - VOLUME_CUBIC_FEET, - VOLUME_CUBIC_METERS, - VOLUME_FLUID_OUNCE, - VOLUME_GALLONS, - VOLUME_LITERS, - VOLUME_MILLILITERS, + UnitOfEnergy, + UnitOfLength, + UnitOfMass, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolume, + UnitOfVolumetricFlux, ) from homeassistant.exceptions import HomeAssistantError from homeassistant.util.unit_conversion import ( @@ -66,48 +31,50 @@ INVALID_SYMBOL = "bob" @pytest.mark.parametrize( "converter,valid_unit", [ - (DistanceConverter, LENGTH_KILOMETERS), - (DistanceConverter, LENGTH_METERS), - (DistanceConverter, LENGTH_CENTIMETERS), - (DistanceConverter, LENGTH_MILLIMETERS), - (DistanceConverter, LENGTH_MILES), - (DistanceConverter, LENGTH_YARD), - (DistanceConverter, LENGTH_FEET), - (DistanceConverter, LENGTH_INCHES), - (EnergyConverter, ENERGY_WATT_HOUR), - (EnergyConverter, ENERGY_KILO_WATT_HOUR), - (EnergyConverter, ENERGY_MEGA_WATT_HOUR), - (MassConverter, MASS_GRAMS), - (MassConverter, MASS_KILOGRAMS), - (MassConverter, MASS_MICROGRAMS), - (MassConverter, MASS_MILLIGRAMS), - (MassConverter, MASS_OUNCES), - (MassConverter, MASS_POUNDS), - (PowerConverter, POWER_WATT), - (PowerConverter, POWER_KILO_WATT), - (PressureConverter, PRESSURE_PA), - (PressureConverter, PRESSURE_HPA), - (PressureConverter, PRESSURE_MBAR), - (PressureConverter, PRESSURE_INHG), - (PressureConverter, PRESSURE_KPA), - (PressureConverter, PRESSURE_CBAR), - (PressureConverter, PRESSURE_MMHG), - (PressureConverter, PRESSURE_PSI), - (SpeedConverter, SPEED_FEET_PER_SECOND), - (SpeedConverter, SPEED_INCHES_PER_DAY), - (SpeedConverter, SPEED_INCHES_PER_HOUR), - (SpeedConverter, SPEED_KILOMETERS_PER_HOUR), - (SpeedConverter, SPEED_KNOTS), - (SpeedConverter, SPEED_METERS_PER_SECOND), - (SpeedConverter, SPEED_MILES_PER_HOUR), - (SpeedConverter, SPEED_MILLIMETERS_PER_DAY), - (TemperatureConverter, TEMP_CELSIUS), - (TemperatureConverter, TEMP_FAHRENHEIT), - (TemperatureConverter, TEMP_KELVIN), - (VolumeConverter, VOLUME_LITERS), - (VolumeConverter, VOLUME_MILLILITERS), - (VolumeConverter, VOLUME_GALLONS), - (VolumeConverter, VOLUME_FLUID_OUNCE), + (DistanceConverter, UnitOfLength.KILOMETERS), + (DistanceConverter, UnitOfLength.METERS), + (DistanceConverter, UnitOfLength.CENTIMETERS), + (DistanceConverter, UnitOfLength.MILLIMETERS), + (DistanceConverter, UnitOfLength.MILES), + (DistanceConverter, UnitOfLength.YARDS), + (DistanceConverter, UnitOfLength.FEET), + (DistanceConverter, UnitOfLength.INCHES), + (EnergyConverter, UnitOfEnergy.WATT_HOUR), + (EnergyConverter, UnitOfEnergy.KILO_WATT_HOUR), + (EnergyConverter, UnitOfEnergy.MEGA_WATT_HOUR), + (EnergyConverter, UnitOfEnergy.GIGA_JOULE), + (MassConverter, UnitOfMass.GRAMS), + (MassConverter, UnitOfMass.KILOGRAMS), + (MassConverter, UnitOfMass.MICROGRAMS), + (MassConverter, UnitOfMass.MILLIGRAMS), + (MassConverter, UnitOfMass.OUNCES), + (MassConverter, UnitOfMass.POUNDS), + (PowerConverter, UnitOfPower.WATT), + (PowerConverter, UnitOfPower.KILO_WATT), + (PressureConverter, UnitOfPressure.PA), + (PressureConverter, UnitOfPressure.HPA), + (PressureConverter, UnitOfPressure.MBAR), + (PressureConverter, UnitOfPressure.INHG), + (PressureConverter, UnitOfPressure.KPA), + (PressureConverter, UnitOfPressure.CBAR), + (PressureConverter, UnitOfPressure.MMHG), + (PressureConverter, UnitOfPressure.PSI), + (SpeedConverter, UnitOfVolumetricFlux.INCHES_PER_DAY), + (SpeedConverter, UnitOfVolumetricFlux.INCHES_PER_HOUR), + (SpeedConverter, UnitOfVolumetricFlux.MILLIMETERS_PER_DAY), + (SpeedConverter, UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR), + (SpeedConverter, UnitOfSpeed.FEET_PER_SECOND), + (SpeedConverter, UnitOfSpeed.KILOMETERS_PER_HOUR), + (SpeedConverter, UnitOfSpeed.KNOTS), + (SpeedConverter, UnitOfSpeed.METERS_PER_SECOND), + (SpeedConverter, UnitOfSpeed.MILES_PER_HOUR), + (TemperatureConverter, UnitOfTemperature.CELSIUS), + (TemperatureConverter, UnitOfTemperature.FAHRENHEIT), + (TemperatureConverter, UnitOfTemperature.KELVIN), + (VolumeConverter, UnitOfVolume.LITERS), + (VolumeConverter, UnitOfVolume.MILLILITERS), + (VolumeConverter, UnitOfVolume.GALLONS), + (VolumeConverter, UnitOfVolume.FLUID_OUNCES), ], ) def test_convert_same_unit(converter: type[BaseUnitConverter], valid_unit: str) -> None: @@ -118,16 +85,16 @@ def test_convert_same_unit(converter: type[BaseUnitConverter], valid_unit: str) @pytest.mark.parametrize( "converter,valid_unit", [ - (DistanceConverter, LENGTH_KILOMETERS), - (EnergyConverter, ENERGY_KILO_WATT_HOUR), - (MassConverter, MASS_GRAMS), - (PowerConverter, POWER_WATT), - (PressureConverter, PRESSURE_PA), - (SpeedConverter, SPEED_KILOMETERS_PER_HOUR), - (TemperatureConverter, TEMP_CELSIUS), - (TemperatureConverter, TEMP_FAHRENHEIT), - (TemperatureConverter, TEMP_KELVIN), - (VolumeConverter, VOLUME_LITERS), + (DistanceConverter, UnitOfLength.KILOMETERS), + (EnergyConverter, UnitOfEnergy.KILO_WATT_HOUR), + (MassConverter, UnitOfMass.GRAMS), + (PowerConverter, UnitOfPower.WATT), + (PressureConverter, UnitOfPressure.PA), + (SpeedConverter, UnitOfSpeed.KILOMETERS_PER_HOUR), + (TemperatureConverter, UnitOfTemperature.CELSIUS), + (TemperatureConverter, UnitOfTemperature.FAHRENHEIT), + (TemperatureConverter, UnitOfTemperature.KELVIN), + (VolumeConverter, UnitOfVolume.LITERS), ], ) def test_convert_invalid_unit( @@ -144,14 +111,14 @@ def test_convert_invalid_unit( @pytest.mark.parametrize( "converter,from_unit,to_unit", [ - (DistanceConverter, LENGTH_KILOMETERS, LENGTH_METERS), - (EnergyConverter, ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR), - (MassConverter, MASS_GRAMS, MASS_KILOGRAMS), - (PowerConverter, POWER_WATT, POWER_KILO_WATT), - (PressureConverter, PRESSURE_HPA, PRESSURE_INHG), - (SpeedConverter, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR), - (TemperatureConverter, TEMP_CELSIUS, TEMP_FAHRENHEIT), - (VolumeConverter, VOLUME_GALLONS, VOLUME_LITERS), + (DistanceConverter, UnitOfLength.KILOMETERS, UnitOfLength.METERS), + (EnergyConverter, UnitOfEnergy.WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR), + (MassConverter, UnitOfMass.GRAMS, UnitOfMass.KILOGRAMS), + (PowerConverter, UnitOfPower.WATT, UnitOfPower.KILO_WATT), + (PressureConverter, UnitOfPressure.HPA, UnitOfPressure.INHG), + (SpeedConverter, UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR), + (TemperatureConverter, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT), + (VolumeConverter, UnitOfVolume.GALLONS, UnitOfVolume.LITERS), ], ) def test_convert_nonnumeric_value( @@ -165,18 +132,33 @@ def test_convert_nonnumeric_value( @pytest.mark.parametrize( "converter,from_unit,to_unit,expected", [ - (DistanceConverter, LENGTH_KILOMETERS, LENGTH_METERS, 1 / 1000), - (EnergyConverter, ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR, 1000), - (PowerConverter, POWER_WATT, POWER_KILO_WATT, 1000), - (PressureConverter, PRESSURE_HPA, PRESSURE_INHG, pytest.approx(33.86389)), + (DistanceConverter, UnitOfLength.KILOMETERS, UnitOfLength.METERS, 1 / 1000), + (EnergyConverter, UnitOfEnergy.WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR, 1000), + (PowerConverter, UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), + ( + PressureConverter, + UnitOfPressure.HPA, + UnitOfPressure.INHG, + pytest.approx(33.86389), + ), ( SpeedConverter, - SPEED_KILOMETERS_PER_HOUR, - SPEED_MILES_PER_HOUR, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, pytest.approx(1.609343), ), - (TemperatureConverter, TEMP_CELSIUS, TEMP_FAHRENHEIT, 1 / 1.8), - (VolumeConverter, VOLUME_GALLONS, VOLUME_LITERS, pytest.approx(0.264172)), + ( + TemperatureConverter, + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + 1 / 1.8, + ), + ( + VolumeConverter, + UnitOfVolume.GALLONS, + UnitOfVolume.LITERS, + pytest.approx(0.264172), + ), ], ) def test_get_unit_ratio( @@ -189,62 +171,107 @@ def test_get_unit_ratio( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (5, LENGTH_MILES, pytest.approx(8.04672), LENGTH_KILOMETERS), - (5, LENGTH_MILES, pytest.approx(8046.72), LENGTH_METERS), - (5, LENGTH_MILES, pytest.approx(804672.0), LENGTH_CENTIMETERS), - (5, LENGTH_MILES, pytest.approx(8046720.0), LENGTH_MILLIMETERS), - (5, LENGTH_MILES, pytest.approx(8800.0), LENGTH_YARD), - (5, LENGTH_MILES, pytest.approx(26400.0008448), LENGTH_FEET), - (5, LENGTH_MILES, pytest.approx(316800.171072), LENGTH_INCHES), - (5, LENGTH_YARD, pytest.approx(0.0045720000000000005), LENGTH_KILOMETERS), - (5, LENGTH_YARD, pytest.approx(4.572), LENGTH_METERS), - (5, LENGTH_YARD, pytest.approx(457.2), LENGTH_CENTIMETERS), - (5, LENGTH_YARD, pytest.approx(4572), LENGTH_MILLIMETERS), - (5, LENGTH_YARD, pytest.approx(0.002840908212), LENGTH_MILES), - (5, LENGTH_YARD, pytest.approx(15.00000048), LENGTH_FEET), - (5, LENGTH_YARD, pytest.approx(180.0000972), LENGTH_INCHES), - (5000, LENGTH_FEET, pytest.approx(1.524), LENGTH_KILOMETERS), - (5000, LENGTH_FEET, pytest.approx(1524), LENGTH_METERS), - (5000, LENGTH_FEET, pytest.approx(152400.0), LENGTH_CENTIMETERS), - (5000, LENGTH_FEET, pytest.approx(1524000.0), LENGTH_MILLIMETERS), - (5000, LENGTH_FEET, pytest.approx(0.9469694040000001), LENGTH_MILES), - (5000, LENGTH_FEET, pytest.approx(1666.66667), LENGTH_YARD), - (5000, LENGTH_FEET, pytest.approx(60000.032400000004), LENGTH_INCHES), - (5000, LENGTH_INCHES, pytest.approx(0.127), LENGTH_KILOMETERS), - (5000, LENGTH_INCHES, pytest.approx(127.0), LENGTH_METERS), - (5000, LENGTH_INCHES, pytest.approx(12700.0), LENGTH_CENTIMETERS), - (5000, LENGTH_INCHES, pytest.approx(127000.0), LENGTH_MILLIMETERS), - (5000, LENGTH_INCHES, pytest.approx(0.078914117), LENGTH_MILES), - (5000, LENGTH_INCHES, pytest.approx(138.88889), LENGTH_YARD), - (5000, LENGTH_INCHES, pytest.approx(416.66668), LENGTH_FEET), - (5, LENGTH_KILOMETERS, pytest.approx(5000), LENGTH_METERS), - (5, LENGTH_KILOMETERS, pytest.approx(500000), LENGTH_CENTIMETERS), - (5, LENGTH_KILOMETERS, pytest.approx(5000000), LENGTH_MILLIMETERS), - (5, LENGTH_KILOMETERS, pytest.approx(3.106855), LENGTH_MILES), - (5, LENGTH_KILOMETERS, pytest.approx(5468.066), LENGTH_YARD), - (5, LENGTH_KILOMETERS, pytest.approx(16404.2), LENGTH_FEET), - (5, LENGTH_KILOMETERS, pytest.approx(196850.5), LENGTH_INCHES), - (5000, LENGTH_METERS, pytest.approx(5), LENGTH_KILOMETERS), - (5000, LENGTH_METERS, pytest.approx(500000), LENGTH_CENTIMETERS), - (5000, LENGTH_METERS, pytest.approx(5000000), LENGTH_MILLIMETERS), - (5000, LENGTH_METERS, pytest.approx(3.106855), LENGTH_MILES), - (5000, LENGTH_METERS, pytest.approx(5468.066), LENGTH_YARD), - (5000, LENGTH_METERS, pytest.approx(16404.2), LENGTH_FEET), - (5000, LENGTH_METERS, pytest.approx(196850.5), LENGTH_INCHES), - (500000, LENGTH_CENTIMETERS, pytest.approx(5), LENGTH_KILOMETERS), - (500000, LENGTH_CENTIMETERS, pytest.approx(5000), LENGTH_METERS), - (500000, LENGTH_CENTIMETERS, pytest.approx(5000000), LENGTH_MILLIMETERS), - (500000, LENGTH_CENTIMETERS, pytest.approx(3.106855), LENGTH_MILES), - (500000, LENGTH_CENTIMETERS, pytest.approx(5468.066), LENGTH_YARD), - (500000, LENGTH_CENTIMETERS, pytest.approx(16404.2), LENGTH_FEET), - (500000, LENGTH_CENTIMETERS, pytest.approx(196850.5), LENGTH_INCHES), - (5000000, LENGTH_MILLIMETERS, pytest.approx(5), LENGTH_KILOMETERS), - (5000000, LENGTH_MILLIMETERS, pytest.approx(5000), LENGTH_METERS), - (5000000, LENGTH_MILLIMETERS, pytest.approx(500000), LENGTH_CENTIMETERS), - (5000000, LENGTH_MILLIMETERS, pytest.approx(3.106855), LENGTH_MILES), - (5000000, LENGTH_MILLIMETERS, pytest.approx(5468.066), LENGTH_YARD), - (5000000, LENGTH_MILLIMETERS, pytest.approx(16404.2), LENGTH_FEET), - (5000000, LENGTH_MILLIMETERS, pytest.approx(196850.5), LENGTH_INCHES), + (5, UnitOfLength.MILES, pytest.approx(8.04672), UnitOfLength.KILOMETERS), + (5, UnitOfLength.MILES, pytest.approx(8046.72), UnitOfLength.METERS), + (5, UnitOfLength.MILES, pytest.approx(804672.0), UnitOfLength.CENTIMETERS), + (5, UnitOfLength.MILES, pytest.approx(8046720.0), UnitOfLength.MILLIMETERS), + (5, UnitOfLength.MILES, pytest.approx(8800.0), UnitOfLength.YARDS), + (5, UnitOfLength.MILES, pytest.approx(26400.0008448), UnitOfLength.FEET), + (5, UnitOfLength.MILES, pytest.approx(316800.171072), UnitOfLength.INCHES), + ( + 5, + UnitOfLength.YARDS, + pytest.approx(0.0045720000000000005), + UnitOfLength.KILOMETERS, + ), + (5, UnitOfLength.YARDS, pytest.approx(4.572), UnitOfLength.METERS), + (5, UnitOfLength.YARDS, pytest.approx(457.2), UnitOfLength.CENTIMETERS), + (5, UnitOfLength.YARDS, pytest.approx(4572), UnitOfLength.MILLIMETERS), + (5, UnitOfLength.YARDS, pytest.approx(0.002840908212), UnitOfLength.MILES), + (5, UnitOfLength.YARDS, pytest.approx(15.00000048), UnitOfLength.FEET), + (5, UnitOfLength.YARDS, pytest.approx(180.0000972), UnitOfLength.INCHES), + (5000, UnitOfLength.FEET, pytest.approx(1.524), UnitOfLength.KILOMETERS), + (5000, UnitOfLength.FEET, pytest.approx(1524), UnitOfLength.METERS), + (5000, UnitOfLength.FEET, pytest.approx(152400.0), UnitOfLength.CENTIMETERS), + (5000, UnitOfLength.FEET, pytest.approx(1524000.0), UnitOfLength.MILLIMETERS), + ( + 5000, + UnitOfLength.FEET, + pytest.approx(0.9469694040000001), + UnitOfLength.MILES, + ), + (5000, UnitOfLength.FEET, pytest.approx(1666.66667), UnitOfLength.YARDS), + ( + 5000, + UnitOfLength.FEET, + pytest.approx(60000.032400000004), + UnitOfLength.INCHES, + ), + (5000, UnitOfLength.INCHES, pytest.approx(0.127), UnitOfLength.KILOMETERS), + (5000, UnitOfLength.INCHES, pytest.approx(127.0), UnitOfLength.METERS), + (5000, UnitOfLength.INCHES, pytest.approx(12700.0), UnitOfLength.CENTIMETERS), + (5000, UnitOfLength.INCHES, pytest.approx(127000.0), UnitOfLength.MILLIMETERS), + (5000, UnitOfLength.INCHES, pytest.approx(0.078914117), UnitOfLength.MILES), + (5000, UnitOfLength.INCHES, pytest.approx(138.88889), UnitOfLength.YARDS), + (5000, UnitOfLength.INCHES, pytest.approx(416.66668), UnitOfLength.FEET), + (5, UnitOfLength.KILOMETERS, pytest.approx(5000), UnitOfLength.METERS), + (5, UnitOfLength.KILOMETERS, pytest.approx(500000), UnitOfLength.CENTIMETERS), + (5, UnitOfLength.KILOMETERS, pytest.approx(5000000), UnitOfLength.MILLIMETERS), + (5, UnitOfLength.KILOMETERS, pytest.approx(3.106855), UnitOfLength.MILES), + (5, UnitOfLength.KILOMETERS, pytest.approx(5468.066), UnitOfLength.YARDS), + (5, UnitOfLength.KILOMETERS, pytest.approx(16404.2), UnitOfLength.FEET), + (5, UnitOfLength.KILOMETERS, pytest.approx(196850.5), UnitOfLength.INCHES), + (5000, UnitOfLength.METERS, pytest.approx(5), UnitOfLength.KILOMETERS), + (5000, UnitOfLength.METERS, pytest.approx(500000), UnitOfLength.CENTIMETERS), + (5000, UnitOfLength.METERS, pytest.approx(5000000), UnitOfLength.MILLIMETERS), + (5000, UnitOfLength.METERS, pytest.approx(3.106855), UnitOfLength.MILES), + (5000, UnitOfLength.METERS, pytest.approx(5468.066), UnitOfLength.YARDS), + (5000, UnitOfLength.METERS, pytest.approx(16404.2), UnitOfLength.FEET), + (5000, UnitOfLength.METERS, pytest.approx(196850.5), UnitOfLength.INCHES), + (500000, UnitOfLength.CENTIMETERS, pytest.approx(5), UnitOfLength.KILOMETERS), + (500000, UnitOfLength.CENTIMETERS, pytest.approx(5000), UnitOfLength.METERS), + ( + 500000, + UnitOfLength.CENTIMETERS, + pytest.approx(5000000), + UnitOfLength.MILLIMETERS, + ), + (500000, UnitOfLength.CENTIMETERS, pytest.approx(3.106855), UnitOfLength.MILES), + (500000, UnitOfLength.CENTIMETERS, pytest.approx(5468.066), UnitOfLength.YARDS), + (500000, UnitOfLength.CENTIMETERS, pytest.approx(16404.2), UnitOfLength.FEET), + ( + 500000, + UnitOfLength.CENTIMETERS, + pytest.approx(196850.5), + UnitOfLength.INCHES, + ), + (5000000, UnitOfLength.MILLIMETERS, pytest.approx(5), UnitOfLength.KILOMETERS), + (5000000, UnitOfLength.MILLIMETERS, pytest.approx(5000), UnitOfLength.METERS), + ( + 5000000, + UnitOfLength.MILLIMETERS, + pytest.approx(500000), + UnitOfLength.CENTIMETERS, + ), + ( + 5000000, + UnitOfLength.MILLIMETERS, + pytest.approx(3.106855), + UnitOfLength.MILES, + ), + ( + 5000000, + UnitOfLength.MILLIMETERS, + pytest.approx(5468.066), + UnitOfLength.YARDS, + ), + (5000000, UnitOfLength.MILLIMETERS, pytest.approx(16404.2), UnitOfLength.FEET), + ( + 5000000, + UnitOfLength.MILLIMETERS, + pytest.approx(196850.5), + UnitOfLength.INCHES, + ), ], ) def test_distance_convert( @@ -260,12 +287,14 @@ def test_distance_convert( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (10, ENERGY_WATT_HOUR, 0.01, ENERGY_KILO_WATT_HOUR), - (10, ENERGY_WATT_HOUR, 0.00001, ENERGY_MEGA_WATT_HOUR), - (10, ENERGY_KILO_WATT_HOUR, 10000, ENERGY_WATT_HOUR), - (10, ENERGY_KILO_WATT_HOUR, 0.01, ENERGY_MEGA_WATT_HOUR), - (10, ENERGY_MEGA_WATT_HOUR, 10000000, ENERGY_WATT_HOUR), - (10, ENERGY_MEGA_WATT_HOUR, 10000, ENERGY_KILO_WATT_HOUR), + (10, UnitOfEnergy.WATT_HOUR, 0.01, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.WATT_HOUR, 0.00001, UnitOfEnergy.MEGA_WATT_HOUR), + (10, UnitOfEnergy.KILO_WATT_HOUR, 10000, UnitOfEnergy.WATT_HOUR), + (10, UnitOfEnergy.KILO_WATT_HOUR, 0.01, UnitOfEnergy.MEGA_WATT_HOUR), + (10, UnitOfEnergy.MEGA_WATT_HOUR, 10000000, UnitOfEnergy.WATT_HOUR), + (10, UnitOfEnergy.MEGA_WATT_HOUR, 10000, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.GIGA_JOULE, 10000 / 3.6, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.GIGA_JOULE, 10 / 3.6, UnitOfEnergy.MEGA_WATT_HOUR), ], ) def test_energy_convert( @@ -281,36 +310,41 @@ def test_energy_convert( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (10, MASS_KILOGRAMS, 10000, MASS_GRAMS), - (10, MASS_KILOGRAMS, 10000000, MASS_MILLIGRAMS), - (10, MASS_KILOGRAMS, 10000000000, MASS_MICROGRAMS), - (10, MASS_KILOGRAMS, pytest.approx(352.73961), MASS_OUNCES), - (10, MASS_KILOGRAMS, pytest.approx(22.046226), MASS_POUNDS), - (10, MASS_GRAMS, 0.01, MASS_KILOGRAMS), - (10, MASS_GRAMS, 10000, MASS_MILLIGRAMS), - (10, MASS_GRAMS, 10000000, MASS_MICROGRAMS), - (10, MASS_GRAMS, pytest.approx(0.35273961), MASS_OUNCES), - (10, MASS_GRAMS, pytest.approx(0.022046226), MASS_POUNDS), - (10, MASS_MILLIGRAMS, 0.00001, MASS_KILOGRAMS), - (10, MASS_MILLIGRAMS, 0.01, MASS_GRAMS), - (10, MASS_MILLIGRAMS, 10000, MASS_MICROGRAMS), - (10, MASS_MILLIGRAMS, pytest.approx(0.00035273961), MASS_OUNCES), - (10, MASS_MILLIGRAMS, pytest.approx(0.000022046226), MASS_POUNDS), - (10000, MASS_MICROGRAMS, 0.00001, MASS_KILOGRAMS), - (10000, MASS_MICROGRAMS, 0.01, MASS_GRAMS), - (10000, MASS_MICROGRAMS, 10, MASS_MILLIGRAMS), - (10000, MASS_MICROGRAMS, pytest.approx(0.00035273961), MASS_OUNCES), - (10000, MASS_MICROGRAMS, pytest.approx(0.000022046226), MASS_POUNDS), - (1, MASS_POUNDS, 0.45359237, MASS_KILOGRAMS), - (1, MASS_POUNDS, 453.59237, MASS_GRAMS), - (1, MASS_POUNDS, 453592.37, MASS_MILLIGRAMS), - (1, MASS_POUNDS, 453592370, MASS_MICROGRAMS), - (1, MASS_POUNDS, 16, MASS_OUNCES), - (16, MASS_OUNCES, 0.45359237, MASS_KILOGRAMS), - (16, MASS_OUNCES, 453.59237, MASS_GRAMS), - (16, MASS_OUNCES, 453592.37, MASS_MILLIGRAMS), - (16, MASS_OUNCES, 453592370, MASS_MICROGRAMS), - (16, MASS_OUNCES, 1, MASS_POUNDS), + (10, UnitOfMass.KILOGRAMS, 10000, UnitOfMass.GRAMS), + (10, UnitOfMass.KILOGRAMS, 10000000, UnitOfMass.MILLIGRAMS), + (10, UnitOfMass.KILOGRAMS, 10000000000, UnitOfMass.MICROGRAMS), + (10, UnitOfMass.KILOGRAMS, pytest.approx(352.73961), UnitOfMass.OUNCES), + (10, UnitOfMass.KILOGRAMS, pytest.approx(22.046226), UnitOfMass.POUNDS), + (10, UnitOfMass.GRAMS, 0.01, UnitOfMass.KILOGRAMS), + (10, UnitOfMass.GRAMS, 10000, UnitOfMass.MILLIGRAMS), + (10, UnitOfMass.GRAMS, 10000000, UnitOfMass.MICROGRAMS), + (10, UnitOfMass.GRAMS, pytest.approx(0.35273961), UnitOfMass.OUNCES), + (10, UnitOfMass.GRAMS, pytest.approx(0.022046226), UnitOfMass.POUNDS), + (10, UnitOfMass.MILLIGRAMS, 0.00001, UnitOfMass.KILOGRAMS), + (10, UnitOfMass.MILLIGRAMS, 0.01, UnitOfMass.GRAMS), + (10, UnitOfMass.MILLIGRAMS, 10000, UnitOfMass.MICROGRAMS), + (10, UnitOfMass.MILLIGRAMS, pytest.approx(0.00035273961), UnitOfMass.OUNCES), + (10, UnitOfMass.MILLIGRAMS, pytest.approx(0.000022046226), UnitOfMass.POUNDS), + (10000, UnitOfMass.MICROGRAMS, 0.00001, UnitOfMass.KILOGRAMS), + (10000, UnitOfMass.MICROGRAMS, 0.01, UnitOfMass.GRAMS), + (10000, UnitOfMass.MICROGRAMS, 10, UnitOfMass.MILLIGRAMS), + (10000, UnitOfMass.MICROGRAMS, pytest.approx(0.00035273961), UnitOfMass.OUNCES), + ( + 10000, + UnitOfMass.MICROGRAMS, + pytest.approx(0.000022046226), + UnitOfMass.POUNDS, + ), + (1, UnitOfMass.POUNDS, 0.45359237, UnitOfMass.KILOGRAMS), + (1, UnitOfMass.POUNDS, 453.59237, UnitOfMass.GRAMS), + (1, UnitOfMass.POUNDS, 453592.37, UnitOfMass.MILLIGRAMS), + (1, UnitOfMass.POUNDS, 453592370, UnitOfMass.MICROGRAMS), + (1, UnitOfMass.POUNDS, 16, UnitOfMass.OUNCES), + (16, UnitOfMass.OUNCES, 0.45359237, UnitOfMass.KILOGRAMS), + (16, UnitOfMass.OUNCES, 453.59237, UnitOfMass.GRAMS), + (16, UnitOfMass.OUNCES, 453592.37, UnitOfMass.MILLIGRAMS), + (16, UnitOfMass.OUNCES, 453592370, UnitOfMass.MICROGRAMS), + (16, UnitOfMass.OUNCES, 1, UnitOfMass.POUNDS), ], ) def test_mass_convert( @@ -326,8 +360,8 @@ def test_mass_convert( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (10, POWER_KILO_WATT, 10000, POWER_WATT), - (10, POWER_WATT, 0.01, POWER_KILO_WATT), + (10, UnitOfPower.KILO_WATT, 10000, UnitOfPower.WATT), + (10, UnitOfPower.WATT, 0.01, UnitOfPower.KILO_WATT), ], ) def test_power_convert( @@ -343,32 +377,32 @@ def test_power_convert( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (1000, PRESSURE_HPA, pytest.approx(14.5037743897), PRESSURE_PSI), - (1000, PRESSURE_HPA, pytest.approx(29.5299801647), PRESSURE_INHG), - (1000, PRESSURE_HPA, pytest.approx(100000), PRESSURE_PA), - (1000, PRESSURE_HPA, pytest.approx(100), PRESSURE_KPA), - (1000, PRESSURE_HPA, pytest.approx(1000), PRESSURE_MBAR), - (1000, PRESSURE_HPA, pytest.approx(100), PRESSURE_CBAR), - (100, PRESSURE_KPA, pytest.approx(14.5037743897), PRESSURE_PSI), - (100, PRESSURE_KPA, pytest.approx(29.5299801647), PRESSURE_INHG), - (100, PRESSURE_KPA, pytest.approx(100000), PRESSURE_PA), - (100, PRESSURE_KPA, pytest.approx(1000), PRESSURE_HPA), - (100, PRESSURE_KPA, pytest.approx(1000), PRESSURE_MBAR), - (100, PRESSURE_KPA, pytest.approx(100), PRESSURE_CBAR), - (30, PRESSURE_INHG, pytest.approx(14.7346266155), PRESSURE_PSI), - (30, PRESSURE_INHG, pytest.approx(101.59167), PRESSURE_KPA), - (30, PRESSURE_INHG, pytest.approx(1015.9167), PRESSURE_HPA), - (30, PRESSURE_INHG, pytest.approx(101591.67), PRESSURE_PA), - (30, PRESSURE_INHG, pytest.approx(1015.9167), PRESSURE_MBAR), - (30, PRESSURE_INHG, pytest.approx(101.59167), PRESSURE_CBAR), - (30, PRESSURE_INHG, pytest.approx(762.002), PRESSURE_MMHG), - (30, PRESSURE_MMHG, pytest.approx(0.580102), PRESSURE_PSI), - (30, PRESSURE_MMHG, pytest.approx(3.99966), PRESSURE_KPA), - (30, PRESSURE_MMHG, pytest.approx(39.9966), PRESSURE_HPA), - (30, PRESSURE_MMHG, pytest.approx(3999.66), PRESSURE_PA), - (30, PRESSURE_MMHG, pytest.approx(39.9966), PRESSURE_MBAR), - (30, PRESSURE_MMHG, pytest.approx(3.99966), PRESSURE_CBAR), - (30, PRESSURE_MMHG, pytest.approx(1.181099), PRESSURE_INHG), + (1000, UnitOfPressure.HPA, pytest.approx(14.5037743897), UnitOfPressure.PSI), + (1000, UnitOfPressure.HPA, pytest.approx(29.5299801647), UnitOfPressure.INHG), + (1000, UnitOfPressure.HPA, pytest.approx(100000), UnitOfPressure.PA), + (1000, UnitOfPressure.HPA, pytest.approx(100), UnitOfPressure.KPA), + (1000, UnitOfPressure.HPA, pytest.approx(1000), UnitOfPressure.MBAR), + (1000, UnitOfPressure.HPA, pytest.approx(100), UnitOfPressure.CBAR), + (100, UnitOfPressure.KPA, pytest.approx(14.5037743897), UnitOfPressure.PSI), + (100, UnitOfPressure.KPA, pytest.approx(29.5299801647), UnitOfPressure.INHG), + (100, UnitOfPressure.KPA, pytest.approx(100000), UnitOfPressure.PA), + (100, UnitOfPressure.KPA, pytest.approx(1000), UnitOfPressure.HPA), + (100, UnitOfPressure.KPA, pytest.approx(1000), UnitOfPressure.MBAR), + (100, UnitOfPressure.KPA, pytest.approx(100), UnitOfPressure.CBAR), + (30, UnitOfPressure.INHG, pytest.approx(14.7346266155), UnitOfPressure.PSI), + (30, UnitOfPressure.INHG, pytest.approx(101.59167), UnitOfPressure.KPA), + (30, UnitOfPressure.INHG, pytest.approx(1015.9167), UnitOfPressure.HPA), + (30, UnitOfPressure.INHG, pytest.approx(101591.67), UnitOfPressure.PA), + (30, UnitOfPressure.INHG, pytest.approx(1015.9167), UnitOfPressure.MBAR), + (30, UnitOfPressure.INHG, pytest.approx(101.59167), UnitOfPressure.CBAR), + (30, UnitOfPressure.INHG, pytest.approx(762), UnitOfPressure.MMHG), + (30, UnitOfPressure.MMHG, pytest.approx(0.580103), UnitOfPressure.PSI), + (30, UnitOfPressure.MMHG, pytest.approx(3.99967), UnitOfPressure.KPA), + (30, UnitOfPressure.MMHG, pytest.approx(39.9967), UnitOfPressure.HPA), + (30, UnitOfPressure.MMHG, pytest.approx(3999.67), UnitOfPressure.PA), + (30, UnitOfPressure.MMHG, pytest.approx(39.9967), UnitOfPressure.MBAR), + (30, UnitOfPressure.MMHG, pytest.approx(3.99967), UnitOfPressure.CBAR), + (30, UnitOfPressure.MMHG, pytest.approx(1.181102), UnitOfPressure.INHG), ], ) def test_pressure_convert( @@ -385,28 +419,65 @@ def test_pressure_convert( "value,from_unit,expected,to_unit", [ # 5 km/h / 1.609 km/mi = 3.10686 mi/h - (5, SPEED_KILOMETERS_PER_HOUR, pytest.approx(3.106856), SPEED_MILES_PER_HOUR), + ( + 5, + UnitOfSpeed.KILOMETERS_PER_HOUR, + pytest.approx(3.106856), + UnitOfSpeed.MILES_PER_HOUR, + ), # 5 mi/h * 1.609 km/mi = 8.04672 km/h - (5, SPEED_MILES_PER_HOUR, 8.04672, SPEED_KILOMETERS_PER_HOUR), + (5, UnitOfSpeed.MILES_PER_HOUR, 8.04672, UnitOfSpeed.KILOMETERS_PER_HOUR), # 5 in/day * 25.4 mm/in = 127 mm/day - (5, SPEED_INCHES_PER_DAY, 127, SPEED_MILLIMETERS_PER_DAY), + ( + 5, + UnitOfVolumetricFlux.INCHES_PER_DAY, + 127, + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + ), # 5 mm/day / 25.4 mm/in = 0.19685 in/day - (5, SPEED_MILLIMETERS_PER_DAY, pytest.approx(0.1968504), SPEED_INCHES_PER_DAY), + ( + 5, + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + pytest.approx(0.1968504), + UnitOfVolumetricFlux.INCHES_PER_DAY, + ), + # 48 mm/day = 2 mm/h + ( + 48, + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + pytest.approx(2), + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + ), # 5 in/hr * 24 hr/day = 3048 mm/day - (5, SPEED_INCHES_PER_HOUR, 3048, SPEED_MILLIMETERS_PER_DAY), + ( + 5, + UnitOfVolumetricFlux.INCHES_PER_HOUR, + 3048, + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + ), # 5 m/s * 39.3701 in/m * 3600 s/hr = 708661 - (5, SPEED_METERS_PER_SECOND, pytest.approx(708661.42), SPEED_INCHES_PER_HOUR), + ( + 5, + UnitOfSpeed.METERS_PER_SECOND, + pytest.approx(708661.42), + UnitOfVolumetricFlux.INCHES_PER_HOUR, + ), # 5000 in/h / 39.3701 in/m / 3600 s/h = 0.03528 m/s ( 5000, - SPEED_INCHES_PER_HOUR, + UnitOfVolumetricFlux.INCHES_PER_HOUR, pytest.approx(0.0352778), - SPEED_METERS_PER_SECOND, + UnitOfSpeed.METERS_PER_SECOND, ), # 5 kt * 1852 m/nmi / 3600 s/h = 2.5722 m/s - (5, SPEED_KNOTS, pytest.approx(2.57222), SPEED_METERS_PER_SECOND), + (5, UnitOfSpeed.KNOTS, pytest.approx(2.57222), UnitOfSpeed.METERS_PER_SECOND), # 5 ft/s * 0.3048 m/ft = 1.524 m/s - (5, SPEED_FEET_PER_SECOND, pytest.approx(1.524), SPEED_METERS_PER_SECOND), + ( + 5, + UnitOfSpeed.FEET_PER_SECOND, + pytest.approx(1.524), + UnitOfSpeed.METERS_PER_SECOND, + ), ], ) def test_speed_convert( @@ -422,12 +493,32 @@ def test_speed_convert( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (100, TEMP_CELSIUS, 212, TEMP_FAHRENHEIT), - (100, TEMP_CELSIUS, 373.15, TEMP_KELVIN), - (100, TEMP_FAHRENHEIT, pytest.approx(37.77777777777778), TEMP_CELSIUS), - (100, TEMP_FAHRENHEIT, pytest.approx(310.92777777777775), TEMP_KELVIN), - (100, TEMP_KELVIN, pytest.approx(-173.15), TEMP_CELSIUS), - (100, TEMP_KELVIN, pytest.approx(-279.66999999999996), TEMP_FAHRENHEIT), + (100, UnitOfTemperature.CELSIUS, 212, UnitOfTemperature.FAHRENHEIT), + (100, UnitOfTemperature.CELSIUS, 373.15, UnitOfTemperature.KELVIN), + ( + 100, + UnitOfTemperature.FAHRENHEIT, + pytest.approx(37.77777777777778), + UnitOfTemperature.CELSIUS, + ), + ( + 100, + UnitOfTemperature.FAHRENHEIT, + pytest.approx(310.92777777777775), + UnitOfTemperature.KELVIN, + ), + ( + 100, + UnitOfTemperature.KELVIN, + pytest.approx(-173.15), + UnitOfTemperature.CELSIUS, + ), + ( + 100, + UnitOfTemperature.KELVIN, + pytest.approx(-279.66999999999996), + UnitOfTemperature.FAHRENHEIT, + ), ], ) def test_temperature_convert( @@ -440,12 +531,22 @@ def test_temperature_convert( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (100, TEMP_CELSIUS, 180, TEMP_FAHRENHEIT), - (100, TEMP_CELSIUS, 100, TEMP_KELVIN), - (100, TEMP_FAHRENHEIT, pytest.approx(55.55555555555556), TEMP_CELSIUS), - (100, TEMP_FAHRENHEIT, pytest.approx(55.55555555555556), TEMP_KELVIN), - (100, TEMP_KELVIN, 100, TEMP_CELSIUS), - (100, TEMP_KELVIN, 180, TEMP_FAHRENHEIT), + (100, UnitOfTemperature.CELSIUS, 180, UnitOfTemperature.FAHRENHEIT), + (100, UnitOfTemperature.CELSIUS, 100, UnitOfTemperature.KELVIN), + ( + 100, + UnitOfTemperature.FAHRENHEIT, + pytest.approx(55.55555555555556), + UnitOfTemperature.CELSIUS, + ), + ( + 100, + UnitOfTemperature.FAHRENHEIT, + pytest.approx(55.55555555555556), + UnitOfTemperature.KELVIN, + ), + (100, UnitOfTemperature.KELVIN, 100, UnitOfTemperature.CELSIUS), + (100, UnitOfTemperature.KELVIN, 180, UnitOfTemperature.FAHRENHEIT), ], ) def test_temperature_convert_with_interval( @@ -458,40 +559,110 @@ def test_temperature_convert_with_interval( @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ - (5, VOLUME_LITERS, pytest.approx(1.32086), VOLUME_GALLONS), - (5, VOLUME_GALLONS, pytest.approx(18.92706), VOLUME_LITERS), - (5, VOLUME_CUBIC_METERS, pytest.approx(176.5733335), VOLUME_CUBIC_FEET), - (500, VOLUME_CUBIC_FEET, pytest.approx(14.1584233), VOLUME_CUBIC_METERS), - (500, VOLUME_CUBIC_FEET, pytest.approx(14.1584233), VOLUME_CUBIC_METERS), - (500, VOLUME_CUBIC_FEET, pytest.approx(478753.2467), VOLUME_FLUID_OUNCE), - (500, VOLUME_CUBIC_FEET, pytest.approx(3740.25974), VOLUME_GALLONS), - (500, VOLUME_CUBIC_FEET, pytest.approx(14158.42329599), VOLUME_LITERS), - (500, VOLUME_CUBIC_FEET, pytest.approx(14158423.29599), VOLUME_MILLILITERS), - (500, VOLUME_CUBIC_METERS, 500, VOLUME_CUBIC_METERS), - (500, VOLUME_CUBIC_METERS, pytest.approx(16907011.35), VOLUME_FLUID_OUNCE), - (500, VOLUME_CUBIC_METERS, pytest.approx(132086.02617), VOLUME_GALLONS), - (500, VOLUME_CUBIC_METERS, 500000, VOLUME_LITERS), - (500, VOLUME_CUBIC_METERS, 500000000, VOLUME_MILLILITERS), - (500, VOLUME_FLUID_OUNCE, pytest.approx(0.52218967), VOLUME_CUBIC_FEET), - (500, VOLUME_FLUID_OUNCE, pytest.approx(0.014786764), VOLUME_CUBIC_METERS), - (500, VOLUME_FLUID_OUNCE, 3.90625, VOLUME_GALLONS), - (500, VOLUME_FLUID_OUNCE, pytest.approx(14.786764), VOLUME_LITERS), - (500, VOLUME_FLUID_OUNCE, pytest.approx(14786.764), VOLUME_MILLILITERS), - (500, VOLUME_GALLONS, pytest.approx(66.84027), VOLUME_CUBIC_FEET), - (500, VOLUME_GALLONS, pytest.approx(1.892706), VOLUME_CUBIC_METERS), - (500, VOLUME_GALLONS, 64000, VOLUME_FLUID_OUNCE), - (500, VOLUME_GALLONS, pytest.approx(1892.70589), VOLUME_LITERS), - (500, VOLUME_GALLONS, pytest.approx(1892705.89), VOLUME_MILLILITERS), - (500, VOLUME_LITERS, pytest.approx(17.65733), VOLUME_CUBIC_FEET), - (500, VOLUME_LITERS, 0.5, VOLUME_CUBIC_METERS), - (500, VOLUME_LITERS, pytest.approx(16907.011), VOLUME_FLUID_OUNCE), - (500, VOLUME_LITERS, pytest.approx(132.086), VOLUME_GALLONS), - (500, VOLUME_LITERS, 500000, VOLUME_MILLILITERS), - (500, VOLUME_MILLILITERS, pytest.approx(0.01765733), VOLUME_CUBIC_FEET), - (500, VOLUME_MILLILITERS, 0.0005, VOLUME_CUBIC_METERS), - (500, VOLUME_MILLILITERS, pytest.approx(16.907), VOLUME_FLUID_OUNCE), - (500, VOLUME_MILLILITERS, pytest.approx(0.132086), VOLUME_GALLONS), - (500, VOLUME_MILLILITERS, 0.5, VOLUME_LITERS), + (5, UnitOfVolume.LITERS, pytest.approx(1.32086), UnitOfVolume.GALLONS), + (5, UnitOfVolume.GALLONS, pytest.approx(18.92706), UnitOfVolume.LITERS), + ( + 5, + UnitOfVolume.CUBIC_METERS, + pytest.approx(176.5733335), + UnitOfVolume.CUBIC_FEET, + ), + ( + 500, + UnitOfVolume.CUBIC_FEET, + pytest.approx(14.1584233), + UnitOfVolume.CUBIC_METERS, + ), + ( + 500, + UnitOfVolume.CUBIC_FEET, + pytest.approx(14.1584233), + UnitOfVolume.CUBIC_METERS, + ), + ( + 500, + UnitOfVolume.CUBIC_FEET, + pytest.approx(478753.2467), + UnitOfVolume.FLUID_OUNCES, + ), + (500, UnitOfVolume.CUBIC_FEET, pytest.approx(3740.25974), UnitOfVolume.GALLONS), + ( + 500, + UnitOfVolume.CUBIC_FEET, + pytest.approx(14158.42329599), + UnitOfVolume.LITERS, + ), + ( + 500, + UnitOfVolume.CUBIC_FEET, + pytest.approx(14158423.29599), + UnitOfVolume.MILLILITERS, + ), + (500, UnitOfVolume.CUBIC_METERS, 500, UnitOfVolume.CUBIC_METERS), + ( + 500, + UnitOfVolume.CUBIC_METERS, + pytest.approx(16907011.35), + UnitOfVolume.FLUID_OUNCES, + ), + ( + 500, + UnitOfVolume.CUBIC_METERS, + pytest.approx(132086.02617), + UnitOfVolume.GALLONS, + ), + (500, UnitOfVolume.CUBIC_METERS, 500000, UnitOfVolume.LITERS), + (500, UnitOfVolume.CUBIC_METERS, 500000000, UnitOfVolume.MILLILITERS), + ( + 500, + UnitOfVolume.FLUID_OUNCES, + pytest.approx(0.52218967), + UnitOfVolume.CUBIC_FEET, + ), + ( + 500, + UnitOfVolume.FLUID_OUNCES, + pytest.approx(0.014786764), + UnitOfVolume.CUBIC_METERS, + ), + (500, UnitOfVolume.FLUID_OUNCES, 3.90625, UnitOfVolume.GALLONS), + (500, UnitOfVolume.FLUID_OUNCES, pytest.approx(14.786764), UnitOfVolume.LITERS), + ( + 500, + UnitOfVolume.FLUID_OUNCES, + pytest.approx(14786.764), + UnitOfVolume.MILLILITERS, + ), + (500, UnitOfVolume.GALLONS, pytest.approx(66.84027), UnitOfVolume.CUBIC_FEET), + (500, UnitOfVolume.GALLONS, pytest.approx(1.892706), UnitOfVolume.CUBIC_METERS), + (500, UnitOfVolume.GALLONS, 64000, UnitOfVolume.FLUID_OUNCES), + (500, UnitOfVolume.GALLONS, pytest.approx(1892.70589), UnitOfVolume.LITERS), + ( + 500, + UnitOfVolume.GALLONS, + pytest.approx(1892705.89), + UnitOfVolume.MILLILITERS, + ), + (500, UnitOfVolume.LITERS, pytest.approx(17.65733), UnitOfVolume.CUBIC_FEET), + (500, UnitOfVolume.LITERS, 0.5, UnitOfVolume.CUBIC_METERS), + (500, UnitOfVolume.LITERS, pytest.approx(16907.011), UnitOfVolume.FLUID_OUNCES), + (500, UnitOfVolume.LITERS, pytest.approx(132.086), UnitOfVolume.GALLONS), + (500, UnitOfVolume.LITERS, 500000, UnitOfVolume.MILLILITERS), + ( + 500, + UnitOfVolume.MILLILITERS, + pytest.approx(0.01765733), + UnitOfVolume.CUBIC_FEET, + ), + (500, UnitOfVolume.MILLILITERS, 0.0005, UnitOfVolume.CUBIC_METERS), + ( + 500, + UnitOfVolume.MILLILITERS, + pytest.approx(16.907), + UnitOfVolume.FLUID_OUNCES, + ), + (500, UnitOfVolume.MILLILITERS, pytest.approx(0.132086), UnitOfVolume.GALLONS), + (500, UnitOfVolume.MILLILITERS, 0.5, UnitOfVolume.LITERS), ], ) def test_volume_convert( diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index a284fd100178b5f7a1584289a70d55a66cfd81ee..03385196cabad245d39183d8c75b6569da191b15 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -1,25 +1,35 @@ """Test the unit system helper.""" +from __future__ import annotations + import pytest +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ACCUMULATED_PRECIPITATION, LENGTH, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILLIMETERS, MASS, - MASS_GRAMS, PRESSURE, - PRESSURE_PA, - SPEED_METERS_PER_SECOND, - TEMP_CELSIUS, TEMPERATURE, VOLUME, - VOLUME_LITERS, WIND_SPEED, + UnitOfLength, + UnitOfMass, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolume, ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem +from homeassistant.util.unit_system import ( + _CONF_UNIT_SYSTEM_IMPERIAL, + _CONF_UNIT_SYSTEM_METRIC, + _CONF_UNIT_SYSTEM_US_CUSTOMARY, + IMPERIAL_SYSTEM, + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, + get_unit_system, +) SYSTEM_NAME = "TEST" INVALID_UNIT = "INVALID" @@ -30,114 +40,121 @@ def test_invalid_units(): with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - INVALID_UNIT, - LENGTH_METERS, - SPEED_METERS_PER_SECOND, - VOLUME_LITERS, - MASS_GRAMS, - PRESSURE_PA, - LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=INVALID_UNIT, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - TEMP_CELSIUS, - INVALID_UNIT, - SPEED_METERS_PER_SECOND, - VOLUME_LITERS, - MASS_GRAMS, - PRESSURE_PA, - LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={}, + length=INVALID_UNIT, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - TEMP_CELSIUS, - LENGTH_METERS, - INVALID_UNIT, - VOLUME_LITERS, - MASS_GRAMS, - PRESSURE_PA, - LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=INVALID_UNIT, ) with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - TEMP_CELSIUS, - LENGTH_METERS, - SPEED_METERS_PER_SECOND, - INVALID_UNIT, - MASS_GRAMS, - PRESSURE_PA, - LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=INVALID_UNIT, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - TEMP_CELSIUS, - LENGTH_METERS, - SPEED_METERS_PER_SECOND, - VOLUME_LITERS, - INVALID_UNIT, - PRESSURE_PA, - LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={}, + length=UnitOfLength.METERS, + mass=INVALID_UNIT, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - TEMP_CELSIUS, - LENGTH_METERS, - SPEED_METERS_PER_SECOND, - VOLUME_LITERS, - MASS_GRAMS, - INVALID_UNIT, - LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfLength.MILLIMETERS, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=INVALID_UNIT, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) with pytest.raises(ValueError): UnitSystem( SYSTEM_NAME, - TEMP_CELSIUS, - LENGTH_METERS, - SPEED_METERS_PER_SECOND, - VOLUME_LITERS, - MASS_GRAMS, - PRESSURE_PA, - INVALID_UNIT, + accumulated_precipitation=INVALID_UNIT, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, ) def test_invalid_value(): """Test no conversion happens if value is non-numeric.""" with pytest.raises(TypeError): - METRIC_SYSTEM.length("25a", LENGTH_KILOMETERS) + METRIC_SYSTEM.length("25a", UnitOfLength.KILOMETERS) with pytest.raises(TypeError): - METRIC_SYSTEM.temperature("50K", TEMP_CELSIUS) + METRIC_SYSTEM.temperature("50K", UnitOfTemperature.CELSIUS) with pytest.raises(TypeError): - METRIC_SYSTEM.wind_speed("50km/h", SPEED_METERS_PER_SECOND) + METRIC_SYSTEM.wind_speed("50km/h", UnitOfSpeed.METERS_PER_SECOND) with pytest.raises(TypeError): - METRIC_SYSTEM.volume("50L", VOLUME_LITERS) + METRIC_SYSTEM.volume("50L", UnitOfVolume.LITERS) with pytest.raises(TypeError): - METRIC_SYSTEM.pressure("50Pa", PRESSURE_PA) + METRIC_SYSTEM.pressure("50Pa", UnitOfPressure.PA) with pytest.raises(TypeError): - METRIC_SYSTEM.accumulated_precipitation("50mm", LENGTH_MILLIMETERS) + METRIC_SYSTEM.accumulated_precipitation("50mm", UnitOfLength.MILLIMETERS) def test_as_dict(): """Test that the as_dict() method returns the expected dictionary.""" expected = { - LENGTH: LENGTH_KILOMETERS, - WIND_SPEED: SPEED_METERS_PER_SECOND, - TEMPERATURE: TEMP_CELSIUS, - VOLUME: VOLUME_LITERS, - MASS: MASS_GRAMS, - PRESSURE: PRESSURE_PA, - ACCUMULATED_PRECIPITATION: LENGTH_MILLIMETERS, + LENGTH: UnitOfLength.KILOMETERS, + WIND_SPEED: UnitOfSpeed.METERS_PER_SECOND, + TEMPERATURE: UnitOfTemperature.CELSIUS, + VOLUME: UnitOfVolume.LITERS, + MASS: UnitOfMass.GRAMS, + PRESSURE: UnitOfPressure.PA, + ACCUMULATED_PRECIPITATION: UnitOfLength.MILLIMETERS, } assert expected == METRIC_SYSTEM.as_dict() @@ -285,16 +302,186 @@ def test_accumulated_precipitation_to_imperial(): def test_properties(): """Test the unit properties are returned as expected.""" - assert METRIC_SYSTEM.length_unit == LENGTH_KILOMETERS - assert METRIC_SYSTEM.wind_speed_unit == SPEED_METERS_PER_SECOND - assert METRIC_SYSTEM.temperature_unit == TEMP_CELSIUS - assert METRIC_SYSTEM.mass_unit == MASS_GRAMS - assert METRIC_SYSTEM.volume_unit == VOLUME_LITERS - assert METRIC_SYSTEM.pressure_unit == PRESSURE_PA - assert METRIC_SYSTEM.accumulated_precipitation_unit == LENGTH_MILLIMETERS + assert METRIC_SYSTEM.length_unit == UnitOfLength.KILOMETERS + assert METRIC_SYSTEM.wind_speed_unit == UnitOfSpeed.METERS_PER_SECOND + assert METRIC_SYSTEM.temperature_unit == UnitOfTemperature.CELSIUS + assert METRIC_SYSTEM.mass_unit == UnitOfMass.GRAMS + assert METRIC_SYSTEM.volume_unit == UnitOfVolume.LITERS + assert METRIC_SYSTEM.pressure_unit == UnitOfPressure.PA + assert METRIC_SYSTEM.accumulated_precipitation_unit == UnitOfLength.MILLIMETERS + + +@pytest.mark.parametrize( + "unit_system, expected_flag", + [ + (METRIC_SYSTEM, True), + (IMPERIAL_SYSTEM, False), + ], +) +def test_is_metric( + caplog: pytest.LogCaptureFixture, unit_system: UnitSystem, expected_flag: bool +): + """Test the is metric flag.""" + assert unit_system.is_metric == expected_flag + assert ( + "Detected code that accesses the `is_metric` property of the unit system." + in caplog.text + ) -def test_is_metric(): - """Test the is metric flag.""" - assert METRIC_SYSTEM.is_metric - assert not IMPERIAL_SYSTEM.is_metric +@pytest.mark.parametrize( + "unit_system, expected_name, expected_private_name", + [ + (METRIC_SYSTEM, _CONF_UNIT_SYSTEM_METRIC, _CONF_UNIT_SYSTEM_METRIC), + (IMPERIAL_SYSTEM, _CONF_UNIT_SYSTEM_IMPERIAL, _CONF_UNIT_SYSTEM_US_CUSTOMARY), + ( + US_CUSTOMARY_SYSTEM, + _CONF_UNIT_SYSTEM_IMPERIAL, + _CONF_UNIT_SYSTEM_US_CUSTOMARY, + ), + ], +) +def test_deprecated_name( + caplog: pytest.LogCaptureFixture, + unit_system: UnitSystem, + expected_name: str, + expected_private_name: str, +) -> None: + """Test the name is deprecated.""" + assert unit_system.name == expected_name + assert unit_system._name == expected_private_name + assert ( + "Detected code that accesses the `name` property of the unit system." + in caplog.text + ) + + +@pytest.mark.parametrize( + "key, expected_system", + [ + (_CONF_UNIT_SYSTEM_METRIC, METRIC_SYSTEM), + (_CONF_UNIT_SYSTEM_US_CUSTOMARY, US_CUSTOMARY_SYSTEM), + ], +) +def test_get_unit_system(key: str, expected_system: UnitSystem) -> None: + """Test get_unit_system.""" + assert get_unit_system(key) is expected_system + + +@pytest.mark.parametrize( + "key", [None, "", "invalid_custom", _CONF_UNIT_SYSTEM_IMPERIAL] +) +def test_get_unit_system_invalid(key: str) -> None: + """Test get_unit_system with an invalid key.""" + with pytest.raises(ValueError, match=f"`{key}` is not a valid unit system key"): + _ = get_unit_system(key) + + +@pytest.mark.parametrize( + "device_class, original_unit, state_unit", + ( + # Test distance conversion + (SensorDeviceClass.DISTANCE, UnitOfLength.FEET, UnitOfLength.METERS), + (SensorDeviceClass.DISTANCE, UnitOfLength.INCHES, UnitOfLength.MILLIMETERS), + (SensorDeviceClass.DISTANCE, UnitOfLength.MILES, UnitOfLength.KILOMETERS), + (SensorDeviceClass.DISTANCE, UnitOfLength.YARDS, UnitOfLength.METERS), + (SensorDeviceClass.DISTANCE, UnitOfLength.KILOMETERS, None), + (SensorDeviceClass.DISTANCE, "very_long", None), + # Test gas meter conversion + (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), + (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_METERS, None), + (SensorDeviceClass.GAS, "very_much", None), + # Test speed conversion + ( + SensorDeviceClass.SPEED, + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + ( + SensorDeviceClass.SPEED, + UnitOfSpeed.MILES_PER_HOUR, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + (SensorDeviceClass.SPEED, UnitOfSpeed.KILOMETERS_PER_HOUR, None), + (SensorDeviceClass.SPEED, UnitOfSpeed.KNOTS, None), + (SensorDeviceClass.SPEED, UnitOfSpeed.METERS_PER_SECOND, None), + (SensorDeviceClass.SPEED, "very_fast", None), + # Test volume conversion + (SensorDeviceClass.VOLUME, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), + (SensorDeviceClass.VOLUME, UnitOfVolume.FLUID_OUNCES, UnitOfVolume.MILLILITERS), + (SensorDeviceClass.VOLUME, UnitOfVolume.GALLONS, UnitOfVolume.LITERS), + (SensorDeviceClass.VOLUME, UnitOfVolume.CUBIC_METERS, None), + (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, None), + (SensorDeviceClass.VOLUME, UnitOfVolume.MILLILITERS, None), + (SensorDeviceClass.VOLUME, "very_much", None), + # Test water meter conversion + (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), + (SensorDeviceClass.WATER, UnitOfVolume.GALLONS, UnitOfVolume.LITERS), + (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_METERS, None), + (SensorDeviceClass.WATER, UnitOfVolume.LITERS, None), + (SensorDeviceClass.WATER, "very_much", None), + ), +) +def test_get_metric_converted_unit_( + device_class: SensorDeviceClass, + original_unit: str, + state_unit: str | None, +) -> None: + """Test unit conversion rules.""" + unit_system = METRIC_SYSTEM + assert unit_system.get_converted_unit(device_class, original_unit) == state_unit + + +@pytest.mark.parametrize( + "device_class, original_unit, state_unit", + ( + # Test distance conversion + (SensorDeviceClass.DISTANCE, UnitOfLength.CENTIMETERS, UnitOfLength.INCHES), + (SensorDeviceClass.DISTANCE, UnitOfLength.KILOMETERS, UnitOfLength.MILES), + (SensorDeviceClass.DISTANCE, UnitOfLength.METERS, UnitOfLength.FEET), + (SensorDeviceClass.DISTANCE, UnitOfLength.MILLIMETERS, UnitOfLength.INCHES), + (SensorDeviceClass.DISTANCE, UnitOfLength.MILES, None), + (SensorDeviceClass.DISTANCE, "very_long", None), + # Test gas meter conversion + (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_FEET), + (SensorDeviceClass.GAS, UnitOfVolume.CUBIC_FEET, None), + (SensorDeviceClass.GAS, "very_much", None), + # Test speed conversion + ( + SensorDeviceClass.SPEED, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, + ), + ( + SensorDeviceClass.SPEED, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, + ), + (SensorDeviceClass.SPEED, UnitOfSpeed.FEET_PER_SECOND, None), + (SensorDeviceClass.SPEED, UnitOfSpeed.KNOTS, None), + (SensorDeviceClass.SPEED, UnitOfSpeed.MILES_PER_HOUR, None), + (SensorDeviceClass.SPEED, "very_fast", None), + # Test volume conversion + (SensorDeviceClass.VOLUME, UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_FEET), + (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, UnitOfVolume.GALLONS), + (SensorDeviceClass.VOLUME, UnitOfVolume.MILLILITERS, UnitOfVolume.FLUID_OUNCES), + (SensorDeviceClass.VOLUME, UnitOfVolume.CUBIC_FEET, None), + (SensorDeviceClass.VOLUME, UnitOfVolume.FLUID_OUNCES, None), + (SensorDeviceClass.VOLUME, UnitOfVolume.GALLONS, None), + (SensorDeviceClass.VOLUME, "very_much", None), + # Test water meter conversion + (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_FEET), + (SensorDeviceClass.WATER, UnitOfVolume.LITERS, UnitOfVolume.GALLONS), + (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_FEET, None), + (SensorDeviceClass.WATER, UnitOfVolume.GALLONS, None), + (SensorDeviceClass.WATER, "very_much", None), + ), +) +def test_get_us_converted_unit( + device_class: SensorDeviceClass, + original_unit: str, + state_unit: str | None, +) -> None: + """Test unit conversion rules.""" + unit_system = US_CUSTOMARY_SYSTEM + assert unit_system.get_converted_unit(device_class, original_unit) == state_unit diff --git a/tox.ini b/tox.ini index b96ab648fa288b86bb089a02eb65e067410f0b36..cbc98968177e0581a4f330561024e223e131b318 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ isolated_build = True [testenv] basepython = {env:PYTHON3_PATH:python3} # pip version duplicated in homeassistant/package_constraints.txt -pip_version = pip>=21.0,<22.3 +pip_version = pip>=21.0,<22.4 install_command = python -m pip install --use-deprecated legacy-resolver {opts} {packages} commands = {envpython} -X dev -m pytest --timeout=9 --durations=10 -n auto --dist=loadfile -qq -o console_output_style=count -p no:sugar {posargs}